Skip to content

Commit 286bd6d

Browse files
borisurbanikclaude
andcommitted
Fix HPA reconciler to skip API delete calls when HPA already absent
ReconcileHpa() was calling DeleteResource() directly when HPA is disabled, which issues a Client().Delete() API call every reconcile cycle regardless of whether the HPA exists. After the first successful delete, subsequent cycles hit a 404 on every cycle indefinitely. Replace with TagObjectToDelete + ReconcileResource, which does a cache-backed Get first and is a no-op when the object is already gone. This is consistent with how PDB and monitoring resources are handled. Add table-driven tests covering all three HPA targets (backend-listener, backend-worker, apicast-production) across enabled, disabled, and async-disable annotation scenarios. Related: THREESCALE-14224 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 8f7ce52 commit 286bd6d

2 files changed

Lines changed: 162 additions & 6 deletions

File tree

pkg/3scale/amp/operator/base_apimanager_logic_reconciler.go

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,6 @@ import (
1818
v1 "k8s.io/api/core/v1"
1919
policyv1 "k8s.io/api/policy/v1"
2020
rbacv1 "k8s.io/api/rbac/v1"
21-
"k8s.io/apimachinery/pkg/api/errors"
2221
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
2322
"k8s.io/apimachinery/pkg/types"
2423
"sigs.k8s.io/controller-runtime/pkg/client"
@@ -209,11 +208,8 @@ func (r *BaseAPIManagerLogicReconciler) ReconcileHpa(desired *hpa.HorizontalPodA
209208
}
210209
}
211210
// Delete HPA in all other cases
212-
err := r.DeleteResource(desired)
213-
if err != nil && !errors.IsNotFound(err) {
214-
return err
215-
}
216-
return nil
211+
helper.TagObjectToDelete(desired)
212+
return r.ReconcileResource(&hpa.HorizontalPodAutoscaler{}, desired, mutateFn)
217213
}
218214

219215
func (r *BaseAPIManagerLogicReconciler) ReconcilePodMonitor(desired *monitoringv1.PodMonitor, mutateFn reconcilers.MutateFn) error {

pkg/3scale/amp/operator/base_apimanager_logic_reconciler_test.go

Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,17 @@ import (
55
"testing"
66

77
appsv1alpha1 "github.com/3scale/3scale-operator/apis/apps/v1alpha1"
8+
"github.com/3scale/3scale-operator/pkg/3scale/amp/component"
89
"github.com/3scale/3scale-operator/pkg/reconcilers"
910

1011
grafanav1alpha1 "github.com/grafana-operator/grafana-operator/v4/api/integreatly/v1alpha1"
1112
monitoringv1 "github.com/prometheus-operator/prometheus-operator/pkg/apis/monitoring/v1"
13+
hpav2 "k8s.io/api/autoscaling/v2"
1214
v1 "k8s.io/api/core/v1"
15+
k8serrors "k8s.io/apimachinery/pkg/api/errors"
1316
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
1417
"k8s.io/apimachinery/pkg/runtime"
18+
"k8s.io/apimachinery/pkg/types"
1519
fakeclientset "k8s.io/client-go/kubernetes/fake"
1620
"k8s.io/client-go/kubernetes/scheme"
1721
"k8s.io/client-go/tools/record"
@@ -400,6 +404,162 @@ func TestBaseAPIManagerLogicReconcilerHasPodMonitors(t *testing.T) {
400404
}
401405
}
402406

407+
func newTestReconciler(t *testing.T, apimanager *appsv1alpha1.APIManager, existingObjs ...runtime.Object) (*BaseAPIManagerLogicReconciler, client.Client) {
408+
t.Helper()
409+
s := scheme.Scheme
410+
if err := appsv1alpha1.AddToScheme(s); err != nil {
411+
t.Fatal(err)
412+
}
413+
414+
objs := append([]runtime.Object{apimanager}, existingObjs...)
415+
cl := fake.NewClientBuilder().WithScheme(s).WithRuntimeObjects(objs...).Build()
416+
clientAPIReader := fake.NewClientBuilder().WithScheme(s).WithRuntimeObjects(objs...).Build()
417+
clientset := fakeclientset.NewSimpleClientset()
418+
recorder := record.NewFakeRecorder(10000)
419+
log := logf.Log.WithName("operator_test")
420+
421+
baseReconciler := reconcilers.NewBaseReconciler(context.Background(), cl, s, clientAPIReader, log, clientset.Discovery(), recorder)
422+
return NewBaseAPIManagerLogicReconciler(baseReconciler, apimanager), cl
423+
}
424+
425+
func TestReconcileHpa(t *testing.T) {
426+
baseAM := func(annotations map[string]string) *appsv1alpha1.APIManager {
427+
am := &appsv1alpha1.APIManager{
428+
ObjectMeta: metav1.ObjectMeta{
429+
Name: apimanagerName,
430+
Namespace: namespace,
431+
Annotations: annotations,
432+
},
433+
Spec: appsv1alpha1.APIManagerSpec{
434+
APIManagerCommonSpec: appsv1alpha1.APIManagerCommonSpec{
435+
WildcardDomain: wildcardDomain,
436+
},
437+
},
438+
}
439+
if _, err := am.SetDefaults(); err != nil {
440+
panic(err)
441+
}
442+
return am
443+
}
444+
445+
hpaExists := func(t *testing.T, cl client.Client, name string) bool {
446+
t.Helper()
447+
obj := &hpav2.HorizontalPodAutoscaler{}
448+
err := cl.Get(context.Background(), types.NamespacedName{Name: name, Namespace: namespace}, obj)
449+
if k8serrors.IsNotFound(err) {
450+
return false
451+
}
452+
if err != nil {
453+
t.Fatalf("unexpected error checking HPA %s: %v", name, err)
454+
}
455+
return true
456+
}
457+
458+
tests := []struct {
459+
name string
460+
apimanager *appsv1alpha1.APIManager
461+
existingHPA *hpav2.HorizontalPodAutoscaler
462+
desired *hpav2.HorizontalPodAutoscaler
463+
assert func(t *testing.T, cl client.Client)
464+
}{
465+
{
466+
name: "disabled with no existing HPA is a no-op",
467+
apimanager: baseAM(nil),
468+
desired: component.DefaultHpa(component.BackendListenerName, namespace),
469+
assert: func(t *testing.T, cl client.Client) {
470+
if hpaExists(t, cl, component.BackendListenerName) {
471+
t.Error("HPA should not exist when disabled and was not pre-existing")
472+
}
473+
},
474+
},
475+
{
476+
name: "disabled with existing HPA deletes it",
477+
apimanager: baseAM(nil),
478+
existingHPA: component.DefaultHpa(component.BackendListenerName, namespace),
479+
desired: component.DefaultHpa(component.BackendListenerName, namespace),
480+
assert: func(t *testing.T, cl client.Client) {
481+
if hpaExists(t, cl, component.BackendListenerName) {
482+
t.Error("HPA should have been deleted when disabled")
483+
}
484+
},
485+
},
486+
{
487+
name: "backend listener HPA enabled creates HPA",
488+
apimanager: func() *appsv1alpha1.APIManager {
489+
am := baseAM(nil)
490+
am.Spec.Backend.ListenerSpec.Hpa = true
491+
return am
492+
}(),
493+
desired: component.DefaultHpa(component.BackendListenerName, namespace),
494+
assert: func(t *testing.T, cl client.Client) {
495+
if !hpaExists(t, cl, component.BackendListenerName) {
496+
t.Error("HPA should have been created when enabled")
497+
}
498+
},
499+
},
500+
{
501+
name: "backend listener HPA enabled with async-disable annotation deletes HPA",
502+
apimanager: func() *appsv1alpha1.APIManager {
503+
am := baseAM(map[string]string{appsv1alpha1.DisableAsyncAnnotation: "true"})
504+
am.Spec.Backend.ListenerSpec.Hpa = true
505+
return am
506+
}(),
507+
existingHPA: component.DefaultHpa(component.BackendListenerName, namespace),
508+
desired: component.DefaultHpa(component.BackendListenerName, namespace),
509+
assert: func(t *testing.T, cl client.Client) {
510+
if hpaExists(t, cl, component.BackendListenerName) {
511+
t.Error("HPA should have been deleted when async is disabled regardless of HPA flag")
512+
}
513+
},
514+
},
515+
{
516+
name: "backend worker HPA enabled creates HPA",
517+
apimanager: func() *appsv1alpha1.APIManager {
518+
am := baseAM(nil)
519+
am.Spec.Backend.WorkerSpec.Hpa = true
520+
return am
521+
}(),
522+
desired: component.DefaultHpa(component.BackendWorkerName, namespace),
523+
assert: func(t *testing.T, cl client.Client) {
524+
if !hpaExists(t, cl, component.BackendWorkerName) {
525+
t.Error("HPA should have been created when enabled")
526+
}
527+
},
528+
},
529+
{
530+
name: "apicast HPA enabled with async-disable annotation creates HPA",
531+
apimanager: func() *appsv1alpha1.APIManager {
532+
am := baseAM(map[string]string{appsv1alpha1.DisableAsyncAnnotation: "true"})
533+
am.Spec.Apicast.ProductionSpec.Hpa = true
534+
return am
535+
}(),
536+
desired: component.DefaultHpa(component.ApicastProductionName, namespace),
537+
assert: func(t *testing.T, cl client.Client) {
538+
if !hpaExists(t, cl, component.ApicastProductionName) {
539+
t.Error("Apicast HPA should be created regardless of async-disable annotation")
540+
}
541+
},
542+
},
543+
}
544+
545+
for _, tt := range tests {
546+
t.Run(tt.name, func(t *testing.T) {
547+
var existingObjs []runtime.Object
548+
if tt.existingHPA != nil {
549+
existingObjs = append(existingObjs, tt.existingHPA)
550+
}
551+
552+
r, cl := newTestReconciler(t, tt.apimanager, existingObjs...)
553+
554+
if err := r.ReconcileHpa(tt.desired, reconcilers.CreateOnlyMutator); err != nil {
555+
t.Fatalf("unexpected error: %v", err)
556+
}
557+
558+
tt.assert(t, cl)
559+
})
560+
}
561+
}
562+
403563
func TestBaseAPIManagerLogicReconcilerHasServiceMonitors(t *testing.T) {
404564
var (
405565
apimanagerName = "example-apimanager"

0 commit comments

Comments
 (0)