From 50b063946ed8fb361c68b4b8e270811436849221 Mon Sep 17 00:00:00 2001 From: Alex Savanovich <40720931+savme@users.noreply.github.com> Date: Mon, 3 Aug 2026 15:47:45 +0200 Subject: [PATCH 1/2] feat: add HPA controller for workload deployments --- cmd/main.go | 33 +- config/components/controller_rbac/role.yaml | 12 + internal/controller/teardown.go | 12 +- internal/controller/testing_helpers_test.go | 2 + .../workloaddeployment_hpa_controller.go | 206 ++++++++++++ .../workloaddeployment_hpa_controller_test.go | 298 ++++++++++++++++++ 6 files changed, 556 insertions(+), 7 deletions(-) create mode 100644 internal/controller/workloaddeployment_hpa_controller.go create mode 100644 internal/controller/workloaddeployment_hpa_controller_test.go diff --git a/cmd/main.go b/cmd/main.go index 24c8bb74..ab19da30 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -16,6 +16,7 @@ import ( // to ensure that exec-entrypoint and run can make use of them. "github.com/KimMachineGun/automemlimit/memlimit" "golang.org/x/sync/errgroup" + autoscalingv2 "k8s.io/api/autoscaling/v2" _ "k8s.io/client-go/plugin/pkg/client/auth" apimeta "k8s.io/apimachinery/pkg/api/meta" @@ -31,6 +32,7 @@ import ( ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/cache" "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/apiutil" "sigs.k8s.io/controller-runtime/pkg/cluster" "sigs.k8s.io/controller-runtime/pkg/healthz" "sigs.k8s.io/controller-runtime/pkg/log/zap" @@ -97,6 +99,19 @@ func init() { // +kubebuilder:scaffold:scheme } +func managedResourceGVKs(s *runtime.Scheme, objs ...client.Object) ([]schema.GroupVersionKind, error) { + gvks := make([]schema.GroupVersionKind, 0, len(objs)) + for _, obj := range objs { + gvk, err := apiutil.GVKForObject(obj, s) + if err != nil { + return nil, err + } + gvks = append(gvks, gvk) + } + + return gvks, nil +} + //nolint:gocyclo // main wires all controller paths; complexity is inherent to startup sequencing func main() { @@ -361,6 +376,11 @@ func main() { setupLog.Error(err, "unable to create controller", "controller", "WorkloadDeployment") os.Exit(1) } + + if err = (&controller.WorkloadDeploymentHPAReconciler{}).SetupWithManager(mgr); err != nil { + setupLog.Error(err, "unable to create controller", "controller", "WorkloadDeploymentHPA") + os.Exit(1) + } } if enableCellControllers { @@ -560,6 +580,15 @@ func initializeClusterDiscovery( return nil, nil, "", nil, fmt.Errorf("unable to create root client for service-catalog: %w", err) } + managedResources, err := managedResourceGVKs( + scheme, + &computev1alpha.Instance{}, + &autoscalingv2.HorizontalPodAutoscaler{}, + ) + if err != nil { + return nil, nil, "", nil, fmt.Errorf("unable to resolve managed resource GVKs: %w", err) + } + provider, err = consumerprovider.New(providerMgr, consumerprovider.Options{ RootClient: rootClient, Scheme: scheme, @@ -570,9 +599,7 @@ func initializeClusterDiscovery( o.Cache.DefaultTransform = cache.TransformStripManagedFields() }, }, - ManagedResources: []schema.GroupVersionKind{ - computev1alpha.GroupVersion.WithKind("Instance"), - }, + ManagedResources: managedResources, Teardowns: []consumerprovider.Teardown{ controller.NewComputeTeardown(quotaClientManager, federationClient, scheme), }, diff --git a/config/components/controller_rbac/role.yaml b/config/components/controller_rbac/role.yaml index e0c6beb5..b945fe9b 100644 --- a/config/components/controller_rbac/role.yaml +++ b/config/components/controller_rbac/role.yaml @@ -24,6 +24,18 @@ rules: verbs: - get - list +- apiGroups: + - autoscaling + resources: + - horizontalpodautoscalers + verbs: + - create + - delete + - get + - list + - patch + - update + - watch - apiGroups: - compute.datumapis.com resources: diff --git a/internal/controller/teardown.go b/internal/controller/teardown.go index 9a287ee7..18625d1d 100644 --- a/internal/controller/teardown.go +++ b/internal/controller/teardown.go @@ -18,10 +18,14 @@ import ( quotametrics "go.datum.net/compute/internal/quota" ) -// labelServiceName is the label key the consumer provider uses to scope -// deactivation cleanup. Every Instance the compute operator creates in a -// consumer project carries this label so disengage can target them by service. -const labelServiceName = "services.miloapis.com/service-name" +// labelServiceName and labelServiceValue are the label key and value the +// consumer provider uses to scope deactivation cleanup. Every resource the +// compute operator creates in a consumer project carries this label so disengage +// can target them by service. +const ( + labelServiceName = "services.miloapis.com/service-name" + labelServiceValue = "compute.datumapis.com" +) // ComputeTeardown implements consumer.Teardown. It is invoked after the // consumer provider has cancelled the per-cluster context and marked labeled diff --git a/internal/controller/testing_helpers_test.go b/internal/controller/testing_helpers_test.go index 17b24f47..52b3ca36 100644 --- a/internal/controller/testing_helpers_test.go +++ b/internal/controller/testing_helpers_test.go @@ -7,6 +7,7 @@ import ( "fmt" "sync" + autoscalingv2 "k8s.io/api/autoscaling/v2" corev1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/runtime" "k8s.io/client-go/tools/events" @@ -26,6 +27,7 @@ import ( // cluster (corev1 + compute). func newProjectScheme() *runtime.Scheme { s := runtime.NewScheme() + _ = autoscalingv2.AddToScheme(s) _ = corev1.AddToScheme(s) _ = computev1alpha.AddToScheme(s) return s diff --git a/internal/controller/workloaddeployment_hpa_controller.go b/internal/controller/workloaddeployment_hpa_controller.go new file mode 100644 index 00000000..6ba5e6cf --- /dev/null +++ b/internal/controller/workloaddeployment_hpa_controller.go @@ -0,0 +1,206 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +package controller + +import ( + "context" + "fmt" + + autoscalingv2 "k8s.io/api/autoscaling/v2" + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" + "sigs.k8s.io/controller-runtime/pkg/log" + "sigs.k8s.io/controller-runtime/pkg/predicate" + mcbuilder "sigs.k8s.io/multicluster-runtime/pkg/builder" + mccontext "sigs.k8s.io/multicluster-runtime/pkg/context" + mcmanager "sigs.k8s.io/multicluster-runtime/pkg/manager" + mcreconcile "sigs.k8s.io/multicluster-runtime/pkg/reconcile" + + computev1alpha "go.datum.net/compute/api/v1alpha" +) + +// WorkloadDeploymentHPAReconciler manages cell-local HorizontalPodAutoscalers +// for WorkloadDeployments that opt into load-driven autoscaling. +type WorkloadDeploymentHPAReconciler struct { + mgr mcmanager.Manager +} + +// +kubebuilder:rbac:groups=compute.datumapis.com,resources=workloaddeployments,verbs=get;list;watch +// +kubebuilder:rbac:groups=autoscaling,resources=horizontalpodautoscalers,verbs=get;list;watch;create;update;patch;delete + +func (r *WorkloadDeploymentHPAReconciler) Reconcile(ctx context.Context, req mcreconcile.Request) (ctrl.Result, error) { + logger := log.FromContext(ctx) + + cl, err := r.mgr.GetCluster(ctx, req.ClusterName) + if err != nil { + return ctrl.Result{}, err + } + + ctx = mccontext.WithCluster(ctx, req.ClusterName) + + var deployment computev1alpha.WorkloadDeployment + if err := cl.GetClient().Get(ctx, req.NamespacedName, &deployment); err != nil { + return ctrl.Result{}, client.IgnoreNotFound(err) + } + + if !deployment.DeletionTimestamp.IsZero() { + return ctrl.Result{}, nil + } + + if !workloadDeploymentAutoscalingEnabled(&deployment) { + return ctrl.Result{}, deleteWorkloadDeploymentHPA(ctx, cl.GetClient(), &deployment) + } + + logger.Info("reconciling deployment HPA") + defer logger.Info("deployment HPA reconcile complete") + + metrics, err := workloadDeploymentHPAMetrics(&deployment) + if err != nil { + return ctrl.Result{}, err + } + + hpa := autoscalingv2.HorizontalPodAutoscaler{ + ObjectMeta: metav1.ObjectMeta{ + Name: deployment.Name, + Namespace: deployment.Namespace, + }, + } + + _, err = controllerutil.CreateOrPatch(ctx, cl.GetClient(), &hpa, func() error { + if hpa.UID != "" && !metav1.IsControlledBy(&hpa, &deployment) { + return fmt.Errorf("HPA %s/%s already exists and is not controlled by WorkloadDeployment %s/%s", + hpa.Namespace, hpa.Name, deployment.Namespace, deployment.Name) + } + + if err := controllerutil.SetControllerReference(&deployment, &hpa, cl.GetScheme()); err != nil { + return err + } + + hpa.Labels = workloadDeploymentHPALabels(&deployment) + + hpa.Spec = autoscalingv2.HorizontalPodAutoscalerSpec{ + ScaleTargetRef: autoscalingv2.CrossVersionObjectReference{ + APIVersion: computev1alpha.GroupVersion.String(), + Kind: "WorkloadDeployment", + Name: deployment.Name, + }, + MinReplicas: new(deployment.Spec.ScaleSettings.MinReplicas), + MaxReplicas: *deployment.Spec.ScaleSettings.MaxReplicas, + Metrics: metrics, + } + + return nil + }) + if err != nil { + return ctrl.Result{}, fmt.Errorf("failed reconciling deployment HPA: %w", err) + } + + return ctrl.Result{}, nil +} + +func workloadDeploymentAutoscalingEnabled(deployment *computev1alpha.WorkloadDeployment) bool { + return deployment.Spec.ScaleSettings.MaxReplicas != nil && len(deployment.Spec.ScaleSettings.Metrics) > 0 +} + +func deleteWorkloadDeploymentHPA(ctx context.Context, c client.Client, deployment *computev1alpha.WorkloadDeployment) error { + var hpa autoscalingv2.HorizontalPodAutoscaler + if err := c.Get(ctx, client.ObjectKey{Namespace: deployment.Namespace, Name: deployment.Name}, &hpa); err != nil { + if apierrors.IsNotFound(err) { + return nil + } + return fmt.Errorf("failed fetching deployment HPA: %w", err) + } + if !metav1.IsControlledBy(&hpa, deployment) { + return nil + } + + if err := c.Delete(ctx, &hpa); client.IgnoreNotFound(err) != nil { + return fmt.Errorf("failed deleting deployment HPA: %w", err) + } + + return nil +} + +func workloadDeploymentHPALabels(deployment *computev1alpha.WorkloadDeployment) map[string]string { + return map[string]string{ + labelServiceName: labelServiceValue, + computev1alpha.WorkloadDeploymentUIDLabel: string(deployment.UID), + computev1alpha.WorkloadDeploymentNameLabel: deployment.Name, + computev1alpha.WorkloadNameLabel: deployment.Spec.WorkloadRef.Name, + computev1alpha.PlacementNameLabel: deployment.Spec.PlacementName, + computev1alpha.CityCodeLabel: deployment.Spec.CityCode, + } +} + +func workloadDeploymentHPAMetrics(deployment *computev1alpha.WorkloadDeployment) ([]autoscalingv2.MetricSpec, error) { + metrics := make([]autoscalingv2.MetricSpec, 0, len(deployment.Spec.ScaleSettings.Metrics)) + for i, metric := range deployment.Spec.ScaleSettings.Metrics { + if metric.Resource == nil { + return nil, fmt.Errorf("metric %d has no resource source", i) + } + + if metric.Resource.Name != corev1.ResourceCPU && metric.Resource.Name != corev1.ResourceMemory { + return nil, fmt.Errorf("metric %d uses unsupported resource %q", i, metric.Resource.Name) + } + + target, err := workloadDeploymentHPAMetricTarget(metric.Resource.Target) + if err != nil { + return nil, fmt.Errorf("metric %d has invalid target: %w", i, err) + } + + metrics = append(metrics, autoscalingv2.MetricSpec{ + Type: autoscalingv2.ResourceMetricSourceType, + Resource: &autoscalingv2.ResourceMetricSource{ + Name: metric.Resource.Name, + Target: target, + }, + }) + } + + return metrics, nil +} + +func workloadDeploymentHPAMetricTarget(target computev1alpha.MetricTarget) (autoscalingv2.MetricTarget, error) { + setTargets := 0 + if target.Value != nil { + setTargets++ + } + if target.AverageValue != nil { + setTargets++ + } + if target.AverageUtilization != nil { + setTargets++ + } + if setTargets != 1 { + return autoscalingv2.MetricTarget{}, fmt.Errorf("exactly one target value must be set") + } + + if target.Value != nil { + value := target.Value.DeepCopy() + return autoscalingv2.MetricTarget{Type: autoscalingv2.ValueMetricType, Value: &value}, nil + } + + if target.AverageValue != nil { + averageValue := target.AverageValue.DeepCopy() + return autoscalingv2.MetricTarget{Type: autoscalingv2.AverageValueMetricType, AverageValue: &averageValue}, nil + } + + return autoscalingv2.MetricTarget{ + Type: autoscalingv2.UtilizationMetricType, + AverageUtilization: new(*target.AverageUtilization), + }, nil +} + +// SetupWithManager sets up the controller with the Manager. +func (r *WorkloadDeploymentHPAReconciler) SetupWithManager(mgr mcmanager.Manager) error { + r.mgr = mgr + + return mcbuilder.ControllerManagedBy(mgr). + For(&computev1alpha.WorkloadDeployment{}, mcbuilder.WithEngageWithLocalCluster(false)). + Owns(&autoscalingv2.HorizontalPodAutoscaler{}, mcbuilder.WithPredicates(predicate.GenerationChangedPredicate{})). + Complete(r) +} diff --git a/internal/controller/workloaddeployment_hpa_controller_test.go b/internal/controller/workloaddeployment_hpa_controller_test.go new file mode 100644 index 00000000..df6cca4e --- /dev/null +++ b/internal/controller/workloaddeployment_hpa_controller_test.go @@ -0,0 +1,298 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +package controller + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + autoscalingv2 "k8s.io/api/autoscaling/v2" + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/api/resource" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + "sigs.k8s.io/multicluster-runtime/pkg/multicluster" + mcreconcile "sigs.k8s.io/multicluster-runtime/pkg/reconcile" + + computev1alpha "go.datum.net/compute/api/v1alpha" +) + +const ( + hpaTestCluster = "test-cluster" + hpaTestUID = "existing-hpa" +) + +func hpaTestDeployment() *computev1alpha.WorkloadDeployment { + averageUtilization := int32(75) + maxReplicas := int32(10) + return &computev1alpha.WorkloadDeployment{ + ObjectMeta: metav1.ObjectMeta{ + Name: wdControllerTestName, + Namespace: wdControllerTestNS, + UID: wdControllerTestUID, + }, + Spec: computev1alpha.WorkloadDeploymentSpec{ + CityCode: wdControllerTestCityCode, + PlacementName: testDefaultPlacement, + WorkloadRef: computev1alpha.WorkloadReference{Name: wdControllerTestWorkload}, + ScaleSettings: computev1alpha.HorizontalScaleSettings{ + MinReplicas: 2, + MaxReplicas: new(maxReplicas), + Metrics: []computev1alpha.MetricSpec{ + { + Resource: &computev1alpha.ResourceMetricSource{ + Name: corev1.ResourceCPU, + Target: computev1alpha.MetricTarget{ + AverageUtilization: new(averageUtilization), + }, + }, + }, + }, + InstanceManagementPolicy: computev1alpha.OrderedReadyInstanceManagementPolicyType, + }, + }, + } +} + +func newHPAReconciler(cl client.Client) *WorkloadDeploymentHPAReconciler { + return &WorkloadDeploymentHPAReconciler{ + mgr: newFakeMCManager(hpaTestCluster, newFakeCluster(cl)), + } +} + +func reconcileHPA(t *testing.T, r *WorkloadDeploymentHPAReconciler, deployment *computev1alpha.WorkloadDeployment) { + t.Helper() + _, err := r.Reconcile(context.Background(), mcreconcile.Request{ + ClusterName: multicluster.ClusterName(hpaTestCluster), + Request: reconcile.Request{NamespacedName: types.NamespacedName{Namespace: deployment.Namespace, Name: deployment.Name}}, + }) + require.NoError(t, err) +} + +func TestWorkloadDeploymentHPAReconciler_CreatesHPA(t *testing.T) { + t.Parallel() + + deployment := hpaTestDeployment() + cl := newProjectFakeClient(deployment) + reconcileHPA(t, newHPAReconciler(cl), deployment) + + var hpa autoscalingv2.HorizontalPodAutoscaler + require.NoError(t, cl.Get(context.Background(), client.ObjectKeyFromObject(deployment), &hpa)) + + assert.Equal(t, computev1alpha.GroupVersion.String(), hpa.Spec.ScaleTargetRef.APIVersion) + assert.Equal(t, "WorkloadDeployment", hpa.Spec.ScaleTargetRef.Kind) + assert.Equal(t, deployment.Name, hpa.Spec.ScaleTargetRef.Name) + require.NotNil(t, hpa.Spec.MinReplicas) + assert.Equal(t, int32(2), *hpa.Spec.MinReplicas) + assert.Equal(t, int32(10), hpa.Spec.MaxReplicas) + + require.Len(t, hpa.Spec.Metrics, 1) + assert.Equal(t, autoscalingv2.ResourceMetricSourceType, hpa.Spec.Metrics[0].Type) + require.NotNil(t, hpa.Spec.Metrics[0].Resource) + assert.Equal(t, corev1.ResourceCPU, hpa.Spec.Metrics[0].Resource.Name) + assert.Equal(t, autoscalingv2.UtilizationMetricType, hpa.Spec.Metrics[0].Resource.Target.Type) + require.NotNil(t, hpa.Spec.Metrics[0].Resource.Target.AverageUtilization) + assert.Equal(t, int32(75), *hpa.Spec.Metrics[0].Resource.Target.AverageUtilization) + + assert.Equal(t, map[string]string{ + labelServiceName: labelServiceValue, + computev1alpha.WorkloadDeploymentUIDLabel: string(deployment.UID), + computev1alpha.WorkloadDeploymentNameLabel: deployment.Name, + computev1alpha.WorkloadNameLabel: deployment.Spec.WorkloadRef.Name, + computev1alpha.PlacementNameLabel: deployment.Spec.PlacementName, + computev1alpha.CityCodeLabel: deployment.Spec.CityCode, + }, hpa.Labels) + require.Len(t, hpa.OwnerReferences, 1) + assert.Equal(t, deployment.Name, hpa.OwnerReferences[0].Name) + assert.True(t, *hpa.OwnerReferences[0].Controller) +} + +func TestWorkloadDeploymentHPAReconciler_UpdatesHPA(t *testing.T) { + t.Parallel() + + deployment := hpaTestDeployment() + existing := &autoscalingv2.HorizontalPodAutoscaler{ + ObjectMeta: metav1.ObjectMeta{ + Name: deployment.Name, + Namespace: deployment.Namespace, + UID: hpaTestUID, + OwnerReferences: []metav1.OwnerReference{ + *metav1.NewControllerRef(deployment, computev1alpha.GroupVersion.WithKind("WorkloadDeployment")), + }, + }, + Spec: autoscalingv2.HorizontalPodAutoscalerSpec{ + ScaleTargetRef: autoscalingv2.CrossVersionObjectReference{APIVersion: "apps/v1", Kind: "Deployment", Name: "old"}, + MinReplicas: new(int32(1)), + MaxReplicas: 1, + }, + } + cl := newProjectFakeClient(deployment, existing) + reconcileHPA(t, newHPAReconciler(cl), deployment) + + var hpa autoscalingv2.HorizontalPodAutoscaler + require.NoError(t, cl.Get(context.Background(), client.ObjectKeyFromObject(deployment), &hpa)) + assert.Equal(t, computev1alpha.GroupVersion.String(), hpa.Spec.ScaleTargetRef.APIVersion) + assert.Equal(t, "WorkloadDeployment", hpa.Spec.ScaleTargetRef.Kind) + assert.Equal(t, int32(10), hpa.Spec.MaxReplicas) +} + +func TestWorkloadDeploymentHPAReconciler_DoesNotAdoptUnownedHPA(t *testing.T) { + t.Parallel() + + deployment := hpaTestDeployment() + existing := &autoscalingv2.HorizontalPodAutoscaler{ + ObjectMeta: metav1.ObjectMeta{Name: deployment.Name, Namespace: deployment.Namespace, UID: hpaTestUID}, + } + cl := newProjectFakeClient(deployment, existing) + r := newHPAReconciler(cl) + + _, err := r.Reconcile(context.Background(), mcreconcile.Request{ + ClusterName: multicluster.ClusterName(hpaTestCluster), + Request: reconcile.Request{NamespacedName: types.NamespacedName{Namespace: deployment.Namespace, Name: deployment.Name}}, + }) + require.Error(t, err) + + var hpa autoscalingv2.HorizontalPodAutoscaler + require.NoError(t, cl.Get(context.Background(), client.ObjectKeyFromObject(deployment), &hpa)) + assert.Empty(t, hpa.OwnerReferences) +} + +func TestWorkloadDeploymentHPAReconciler_DeletesHPAWhenAutoscalingDisabled(t *testing.T) { + t.Parallel() + + deployment := hpaTestDeployment() + deployment.Spec.ScaleSettings.MaxReplicas = nil + existing := &autoscalingv2.HorizontalPodAutoscaler{ + ObjectMeta: metav1.ObjectMeta{ + Name: deployment.Name, + Namespace: deployment.Namespace, + UID: hpaTestUID, + OwnerReferences: []metav1.OwnerReference{ + *metav1.NewControllerRef(deployment, computev1alpha.GroupVersion.WithKind("WorkloadDeployment")), + }, + }, + } + cl := newProjectFakeClient(deployment, existing) + reconcileHPA(t, newHPAReconciler(cl), deployment) + + var hpa autoscalingv2.HorizontalPodAutoscaler + err := cl.Get(context.Background(), client.ObjectKeyFromObject(deployment), &hpa) + assert.True(t, apierrors.IsNotFound(err)) +} + +func TestWorkloadDeploymentHPAReconciler_DoesNotDeleteUnownedHPA(t *testing.T) { + t.Parallel() + + deployment := hpaTestDeployment() + deployment.Spec.ScaleSettings.MaxReplicas = nil + existing := &autoscalingv2.HorizontalPodAutoscaler{ + ObjectMeta: metav1.ObjectMeta{Name: deployment.Name, Namespace: deployment.Namespace, UID: hpaTestUID}, + } + cl := newProjectFakeClient(deployment, existing) + reconcileHPA(t, newHPAReconciler(cl), deployment) + + var hpa autoscalingv2.HorizontalPodAutoscaler + require.NoError(t, cl.Get(context.Background(), client.ObjectKeyFromObject(deployment), &hpa)) + assert.Empty(t, hpa.OwnerReferences) +} + +func TestWorkloadDeploymentHPAMetrics(t *testing.T) { + t.Parallel() + + value := resource.MustParse("100m") + averageValue := resource.MustParse("256Mi") + averageUtilization := int32(80) + + tests := []struct { + name string + metrics []computev1alpha.MetricSpec + wantType autoscalingv2.MetricTargetType + wantErr bool + wantMetric corev1.ResourceName + }{ + { + name: "value", + metrics: []computev1alpha.MetricSpec{{Resource: &computev1alpha.ResourceMetricSource{ + Name: corev1.ResourceCPU, + Target: computev1alpha.MetricTarget{Value: &value}, + }}}, + wantType: autoscalingv2.ValueMetricType, + wantMetric: corev1.ResourceCPU, + }, + { + name: "average value", + metrics: []computev1alpha.MetricSpec{{Resource: &computev1alpha.ResourceMetricSource{ + Name: corev1.ResourceMemory, + Target: computev1alpha.MetricTarget{AverageValue: &averageValue}, + }}}, + wantType: autoscalingv2.AverageValueMetricType, + wantMetric: corev1.ResourceMemory, + }, + { + name: "average utilization", + metrics: []computev1alpha.MetricSpec{{Resource: &computev1alpha.ResourceMetricSource{ + Name: corev1.ResourceCPU, + Target: computev1alpha.MetricTarget{AverageUtilization: &averageUtilization}, + }}}, + wantType: autoscalingv2.UtilizationMetricType, + wantMetric: corev1.ResourceCPU, + }, + { + name: "missing resource", + metrics: []computev1alpha.MetricSpec{{}}, + wantErr: true, + }, + { + name: "unsupported resource", + metrics: []computev1alpha.MetricSpec{{Resource: &computev1alpha.ResourceMetricSource{ + Name: corev1.ResourceEphemeralStorage, + Target: computev1alpha.MetricTarget{AverageUtilization: &averageUtilization}, + }}}, + wantErr: true, + }, + { + name: "multiple targets", + metrics: []computev1alpha.MetricSpec{{Resource: &computev1alpha.ResourceMetricSource{ + Name: corev1.ResourceCPU, + Target: computev1alpha.MetricTarget{ + Value: &value, + AverageUtilization: &averageUtilization, + }, + }}}, + wantErr: true, + }, + { + name: "missing target", + metrics: []computev1alpha.MetricSpec{{Resource: &computev1alpha.ResourceMetricSource{ + Name: corev1.ResourceCPU, + }}}, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + deployment := hpaTestDeployment() + deployment.Spec.ScaleSettings.Metrics = tt.metrics + + metrics, err := workloadDeploymentHPAMetrics(deployment) + if tt.wantErr { + require.Error(t, err) + return + } + + require.NoError(t, err) + require.Len(t, metrics, 1) + require.NotNil(t, metrics[0].Resource) + assert.Equal(t, tt.wantMetric, metrics[0].Resource.Name) + assert.Equal(t, tt.wantType, metrics[0].Resource.Target.Type) + }) + } +} From 77e6dcab229e4d03c212980b6adb900ae59e30bd Mon Sep 17 00:00:00 2001 From: Alex Savanovich <40720931+savme@users.noreply.github.com> Date: Tue, 4 Aug 2026 14:10:20 +0200 Subject: [PATCH 2/2] fix: name WorkloadDeployment HPA controller --- internal/controller/workloaddeployment_hpa_controller.go | 1 + 1 file changed, 1 insertion(+) diff --git a/internal/controller/workloaddeployment_hpa_controller.go b/internal/controller/workloaddeployment_hpa_controller.go index 6ba5e6cf..b1cd0aff 100644 --- a/internal/controller/workloaddeployment_hpa_controller.go +++ b/internal/controller/workloaddeployment_hpa_controller.go @@ -200,6 +200,7 @@ func (r *WorkloadDeploymentHPAReconciler) SetupWithManager(mgr mcmanager.Manager r.mgr = mgr return mcbuilder.ControllerManagedBy(mgr). + Named("workload-deployment-hpa"). For(&computev1alpha.WorkloadDeployment{}, mcbuilder.WithEngageWithLocalCluster(false)). Owns(&autoscalingv2.HorizontalPodAutoscaler{}, mcbuilder.WithPredicates(predicate.GenerationChangedPredicate{})). Complete(r)