Skip to content

Commit 6f8e047

Browse files
nadaverellclaude
andauthored
Require HPA coverage before treating the evidence as conclusive (#1755)
Closes #1749. `HPAEvidenceAvailable` is not a caveat — it is a safety gate. Rightsizing withholds a reduction when `Fit == FitOversized && !HPAEvidenceAvailable`, because shrinking a request an autoscaler is scaling on is the one mistake that matters here. A false positive therefore does not merely mislead; it lets the reduction through. A synced, non-nil HPA informer answers an empty list for every namespace it does not watch, and it answers **without an error**. Both rightsizing paths read that as "this workload has no autoscaler" and set the flag. Radar falls back to namespace-scoped informers whenever cluster-wide list is denied, so this is reachable in ordinary multi-tenant operation rather than a corner case. ## What changed Availability now follows the informer's own coverage, decided **per workload** — a cluster-wide scan can span a watched and an unwatched namespace at once, so a single answer for the whole scan would be wrong either way. Coverage is consulted via `KindCoversNamespace`, deliberately rather than asking whether the informer is cluster-wide. A multi-namespace informer is authoritative for every namespace it actually watches, and testing cluster-wide-ness would have suppressed valid recommendations in all of them. ## Full access is unchanged by construction `NewResourceCache` normalises an unrestricted cache to `Enabled: true` with an empty namespace, for which `KindCoversNamespace` returns true for every namespace. Both new gates are no-ops there. The reverse — that this cannot newly suppress a valid recommendation — holds for the same reason: coverage is derived from the same `resourceScopeNamespaces` used to build the informer, so a namespace that is watched is a namespace that is covered. ## Testing Go tests pin both directions: full access marks every workload available, and an HPA informer scoped to one namespace marks only that namespace's workloads — driven through `enrichScanHPA` with a real utilization-based HPA, so target matching and the `hpaManaged` flag are exercised rather than just the marking helper. `go build ./...`, `go vet`, `go test ./internal/prometheus/` (including `-race`), and the separate `pkg/k8score` module tests pass. **Live, against GKE with real Prometheus and kube-state-metrics.** Binaries from this branch and from `main` were run against the same cluster under two identities. *Full access — the case that must not change:* | | `main` | this branch | |---|---|---| | `Deployment staging/koala-backend` | `hpaEvidenceAvailable=true`, same fit and reason | identical | | Cluster scan, 26 workloads / 52 rows | `hpaEvidenceAvailable` true on 52 | true on 52 | | Decision fields across 52 rows | — | identical across repeat runs | Repeat scans differed only in measured values (observed, sample counts), and those differences moved between runs and workloads — live drift, not behaviour. *HPA readable in `staging` only — the condition the bug needs.* A throwaway ServiceAccount was granted everything cluster-wide except `horizontalpodautoscalers`, plus a Role granting HPA reads in `staging` alone, which drives Radar to a namespace-scoped HPA informer (`Using namespace-scoped informers for namespace(s): ["staging"]`). | `Deployment dev/finops` (HPAs **not** readable in `dev`) | `main` | this branch | |---|---|---| | `hpaEvidenceAvailable` | `true` | **`false`** | | `recommendationReason` | *none* | `hpa_evidence_unavailable` | | Recommended request | **cpu `100m`, memory `256Mi`** | *withheld* | `main` suggests shrinking a workload whose autoscaler it could never have seen. In the same run, `Deployment staging/koala-backend` — where the informer does watch the namespace — is **identical on both builds**, recommendations included, so the gate suppresses only what it cannot see. The ServiceAccount, Role, RoleBinding, ClusterRole, ClusterRoleBinding and namespace were deleted afterwards and verified gone. ## Follow-up Both paths still reject HPA evidence while *any* deferred informer is warming or has failed, even when the HPA informer itself is fully synced and cluster-wide — a false negative that suppresses valid recommendations. It predates this change and fixing it here would alter full-access behaviour, which this PR deliberately does not do. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_0125QDX24TNuod7FKLwTDhvW <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Medium Risk** > Changes the safety gate that blocks downsize recommendations when HPA inventory may be incomplete; behavior is intentionally stricter for namespace-scoped informers but full-access paths are designed to stay equivalent. > > **Overview** > Fixes a safety bug where **namespace-scoped HPA informers** return an empty list (with no error) for namespaces they do not watch, and rightsizing treated that as proof there was no autoscaler—allowing **CPU/memory reduction** recommendations that should be blocked when HPA evidence is unknown. > > Both the **single-workload** path (`loadHPAManagedResources`) and the **cluster scan** path (`markScanHPAAvailable`) now call **`KindCoversNamespace`** per workload namespace before treating an HPA list as authoritative. Workloads outside the informer’s scope no longer get `hpaEvidenceAvailable` / `hpaAvailable`, so oversized fits stay withheld with **`hpa_evidence_unavailable`** instead of a shrink. Unrestricted caches are unchanged because full coverage still marks every namespace as readable. > > Tests cover scoped vs full access, the marking helper, and end-to-end **`enrichScanHPA`** with a real utilization-based HPA. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit f64b5fd. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY --> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 672f967 commit 6f8e047

3 files changed

Lines changed: 152 additions & 6 deletions

File tree

‎internal/prometheus/rightsizing.go‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import (
1414
"github.com/go-chi/chi/v5"
1515
"github.com/skyhook-io/radar/internal/errorlog"
1616
"github.com/skyhook-io/radar/internal/k8s"
17+
"github.com/skyhook-io/radar/pkg/k8score"
1718
"github.com/skyhook-io/radar/pkg/prom"
1819
autoscalingv2 "k8s.io/api/autoscaling/v2"
1920
corev1 "k8s.io/api/core/v1"
@@ -336,6 +337,13 @@ func loadHPAManagedResources(cache *k8s.ResourceCache, kind, namespace, name str
336337
if !cache.IsDeferredSynced() || cache.HorizontalPodAutoscalers() == nil {
337338
return managed, false
338339
}
340+
// A namespace-scoped informer answers an empty list for every namespace it
341+
// does not watch, with no error. Treating that as "no autoscaler here" is
342+
// what the availability flag exists to prevent: it gates whether a
343+
// reduction may be suggested at all.
344+
if !cache.KindCoversNamespace(string(k8score.HorizontalPodAutoscalers), namespace) {
345+
return managed, false
346+
}
339347
hpas, err := cache.HorizontalPodAutoscalers().HorizontalPodAutoscalers(namespace).List(labels.Everything())
340348
if err != nil {
341349
return managed, false

‎internal/prometheus/rightsizing_scan.go‎

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import (
1010
"time"
1111

1212
"github.com/skyhook-io/radar/internal/k8s"
13+
"github.com/skyhook-io/radar/pkg/k8score"
1314
"github.com/skyhook-io/radar/pkg/prom"
1415
appsv1 "k8s.io/api/apps/v1"
1516
autoscalingv2 "k8s.io/api/autoscaling/v2"
@@ -723,15 +724,15 @@ func enrichScanHPA(cache *k8s.ResourceCache, scopes map[string][]string, workloa
723724
if err != nil {
724725
return
725726
}
726-
markScanHPAAvailable(workloads, "")
727+
markScanHPAAvailable(cache, workloads, "")
727728
} else {
728729
for _, namespace := range namespaces {
729730
items, err := lister.HorizontalPodAutoscalers(namespace).List(labels.Everything())
730731
if err != nil {
731732
continue
732733
}
733734
hpas = append(hpas, items...)
734-
markScanHPAAvailable(workloads, namespace)
735+
markScanHPAAvailable(cache, workloads, namespace)
735736
}
736737
}
737738
for _, hpa := range hpas {
@@ -751,11 +752,19 @@ func enrichScanHPA(cache *k8s.ResourceCache, scopes map[string][]string, workloa
751752
}
752753
}
753754

754-
func markScanHPAAvailable(workloads map[string]*scanWorkload, namespace string) {
755+
// markScanHPAAvailable records that the HPA inventory was actually readable for
756+
// these workloads. A successful list says nothing about namespaces the informer
757+
// does not watch, so coverage is checked per workload: a cluster-wide scan can
758+
// span both kinds of namespace at once.
759+
func markScanHPAAvailable(cache *k8s.ResourceCache, workloads map[string]*scanWorkload, namespace string) {
755760
for _, workload := range workloads {
756-
if namespace == "" || workload.namespace == namespace {
757-
workload.workload.hpaAvailable = true
761+
if namespace != "" && workload.namespace != namespace {
762+
continue
763+
}
764+
if !cache.KindCoversNamespace(string(k8score.HorizontalPodAutoscalers), workload.namespace) {
765+
continue
758766
}
767+
workload.workload.hpaAvailable = true
759768
}
760769
}
761770

‎internal/prometheus/rightsizing_scan_test.go‎

Lines changed: 130 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import (
1010
"time"
1111

1212
appsv1 "k8s.io/api/apps/v1"
13+
autoscalingv2 "k8s.io/api/autoscaling/v2"
1314
corev1 "k8s.io/api/core/v1"
1415
"k8s.io/apimachinery/pkg/api/resource"
1516
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
@@ -338,7 +339,7 @@ func TestMarkScanHPAAvailableIsNamespaceScoped(t *testing.T) {
338339
"a": {namespace: "team-a"},
339340
"b": {namespace: "team-b"},
340341
}
341-
markScanHPAAvailable(workloads, "team-a")
342+
markScanHPAAvailable(scopeTestCache(t, map[string]bool{k8score.HorizontalPodAutoscalers: true}), workloads, "team-a")
342343
if !workloads["a"].workload.hpaAvailable || workloads["b"].workload.hpaAvailable {
343344
t.Fatalf("HPA evidence availability crossed namespaces: %+v", workloads)
344345
}
@@ -487,3 +488,131 @@ func TestScanSkipsReplicaSetWaitWithoutDeployments(t *testing.T) {
487488
t.Errorf("waited %v on ReplicaSets a DaemonSet-only scan never reads", elapsed)
488489
}
489490
}
491+
492+
// The availability flag gates whether a reduction may be suggested at all, so
493+
// an HPA informer that does not watch a namespace must not report that it
494+
// looked there and found no autoscaler.
495+
func TestScanHPAAvailabilityFollowsInformerCoverage(t *testing.T) {
496+
core, err := k8score.NewResourceCache(k8score.CacheConfig{
497+
Client: fake.NewClientset(),
498+
ResourceTypes: map[string]bool{k8score.HorizontalPodAutoscalers: true},
499+
DeferredTypes: map[string]bool{},
500+
ResourceScopes: map[string]k8score.ResourceScope{
501+
string(k8score.HorizontalPodAutoscalers): {Enabled: true, Namespace: "team-a"},
502+
},
503+
})
504+
if err != nil {
505+
t.Fatalf("NewResourceCache: %v", err)
506+
}
507+
t.Cleanup(core.Stop)
508+
cache := &k8s.ResourceCache{ResourceCache: core}
509+
510+
workloads := map[string]*scanWorkload{
511+
"a": {kind: "Deployment", namespace: "team-a", name: "watched"},
512+
"b": {kind: "Deployment", namespace: "team-b", name: "unwatched"},
513+
}
514+
markScanHPAAvailable(cache, workloads, "")
515+
516+
if !workloads["a"].workload.hpaAvailable {
517+
t.Error("team-a is watched; its empty HPA list is authoritative and must count as evidence")
518+
}
519+
if workloads["b"].workload.hpaAvailable {
520+
t.Error("team-b is outside the informer's scope, so no autoscaler could have been seen there")
521+
}
522+
}
523+
524+
// A cache with no scope restrictions watches every namespace, so there is no
525+
// namespace whose HPAs went unread: every workload's empty result is a real
526+
// answer, and the evidence counts for all of them.
527+
func TestScanHPAAvailabilityWithFullAccess(t *testing.T) {
528+
cache := scopeTestCache(t, map[string]bool{k8score.HorizontalPodAutoscalers: true})
529+
workloads := map[string]*scanWorkload{
530+
"a": {kind: "Deployment", namespace: "team-a", name: "one"},
531+
"b": {kind: "Deployment", namespace: "team-b", name: "two"},
532+
}
533+
markScanHPAAvailable(cache, workloads, "")
534+
if !workloads["a"].workload.hpaAvailable || !workloads["b"].workload.hpaAvailable {
535+
t.Fatalf("full access must mark every workload available: %+v", workloads)
536+
}
537+
}
538+
539+
// The real caller, not just the marking helper: a utilization HPA in the
540+
// watched namespace must both flag its workload as HPA-managed and count as
541+
// evidence, while a workload in an unwatched namespace gets neither — nothing
542+
// was ever listed there, so "no autoscaler" was never established.
543+
func TestEnrichScanHPAHonoursCoverageEndToEnd(t *testing.T) {
544+
utilization := int32(80)
545+
hpa := &autoscalingv2.HorizontalPodAutoscaler{
546+
ObjectMeta: metav1.ObjectMeta{Namespace: "team-a", Name: "api"},
547+
Spec: autoscalingv2.HorizontalPodAutoscalerSpec{
548+
ScaleTargetRef: autoscalingv2.CrossVersionObjectReference{Kind: "Deployment", Name: "api"},
549+
Metrics: []autoscalingv2.MetricSpec{{
550+
Type: autoscalingv2.ResourceMetricSourceType,
551+
Resource: &autoscalingv2.ResourceMetricSource{
552+
Name: "cpu",
553+
Target: autoscalingv2.MetricTarget{AverageUtilization: &utilization},
554+
},
555+
}},
556+
},
557+
}
558+
for _, tc := range []struct {
559+
name string
560+
scopes map[string]k8score.ResourceScope
561+
wantAAvailable bool
562+
wantBAvailable bool
563+
}{
564+
{"full access", nil, true, true},
565+
{"scoped to team-a", map[string]k8score.ResourceScope{
566+
string(k8score.HorizontalPodAutoscalers): {Enabled: true, Namespace: "team-a"},
567+
}, true, false},
568+
} {
569+
t.Run(tc.name, func(t *testing.T) {
570+
core, err := k8score.NewResourceCache(k8score.CacheConfig{
571+
Client: fake.NewClientset(hpa),
572+
ResourceTypes: map[string]bool{k8score.HorizontalPodAutoscalers: true},
573+
DeferredTypes: map[string]bool{},
574+
ResourceScopes: tc.scopes,
575+
})
576+
if err != nil {
577+
t.Fatalf("NewResourceCache: %v", err)
578+
}
579+
t.Cleanup(core.Stop)
580+
cache := &k8s.ResourceCache{ResourceCache: core}
581+
// HPAs load on the deferred path, and the evidence gate refuses to
582+
// read them until that phase reports done.
583+
deadline := time.Now().Add(10 * time.Second)
584+
for !cache.IsDeferredSynced() {
585+
if time.Now().After(deadline) {
586+
t.Fatal("deferred informers never reported synced")
587+
}
588+
time.Sleep(20 * time.Millisecond)
589+
}
590+
591+
workloads := map[string]*scanWorkload{
592+
workloadIdentity("Deployment", "team-a", "api"): {
593+
kind: "Deployment", namespace: "team-a", name: "api",
594+
workload: rightsizingWorkload{hpaManaged: map[string]bool{}},
595+
},
596+
workloadIdentity("Deployment", "team-b", "other"): {
597+
kind: "Deployment", namespace: "team-b", name: "other",
598+
workload: rightsizingWorkload{hpaManaged: map[string]bool{}},
599+
},
600+
}
601+
// A kind mapped to a nil namespace list is the cluster-wide scope; an
602+
// absent key yields an empty list, which is a different branch.
603+
enrichScanHPA(cache, map[string][]string{"Deployment": nil}, workloads)
604+
605+
a := workloads[workloadIdentity("Deployment", "team-a", "api")].workload
606+
b := workloads[workloadIdentity("Deployment", "team-b", "other")].workload
607+
if a.hpaAvailable != tc.wantAAvailable {
608+
t.Errorf("team-a hpaAvailable = %v, want %v", a.hpaAvailable, tc.wantAAvailable)
609+
}
610+
if b.hpaAvailable != tc.wantBAvailable {
611+
t.Errorf("team-b hpaAvailable = %v, want %v", b.hpaAvailable, tc.wantBAvailable)
612+
}
613+
if !a.hpaManaged["cpu"] {
614+
t.Error("the HPA targets this Deployment's CPU; that must survive the coverage check")
615+
}
616+
})
617+
}
618+
}

0 commit comments

Comments
 (0)