Skip to content

feat: support LimitRange defaults for current requests/limits - #881

Open
abhyudayareddy wants to merge 1 commit into
FairwindsOps:masterfrom
abhyudayareddy:limitrange-support-315
Open

feat: support LimitRange defaults for current requests/limits#881
abhyudayareddy wants to merge 1 commit into
FairwindsOps:masterfrom
abhyudayareddy:limitrange-support-315

Conversation

@abhyudayareddy

Copy link
Copy Markdown
Contributor

Problem

Closes #315.

When a container doesn't set its own resources.requests/resources.limits, but the namespace has a LimitRange that provides defaults, Goldilocks previously showed "Not Set" for those values -- even though a real Pod created from that workload would actually receive an effective value from Kubernetes. This made the "current" side of the dashboard misleading in namespaces that rely on LimitRange for baseline sizing.

What changed

Summarizer.GetSummary() (pkg/summary/summary.go) now lists each namespace's LimitRange objects (via the existing kube.ClientInstance, the same client wrapper already used elsewhere) and resolves each container's effective requests/limits before building its ContainerSummary, instead of using the container's raw spec directly.

Kubernetes semantics implemented, and why

This intentionally reproduces two separate, ordered steps that real Kubernetes performs, because the order changes the outcome for a case that's easy to get backwards:

  1. Pod-level API defaulting (SetDefaults_Pod, pkg/apis/core/v1/defaults.go): if a container explicitly sets a Limit for a resource but leaves the Request unset, the Request is defaulted to that explicit Limit. This is decode-time type defaulting that runs before any admission plugin (including LimitRanger) ever sees the object, and it only ever looks at what the container itself already specifies.
  2. LimitRanger admission (plugin/pkg/admission/limitranger/admission.go, mergeContainerResources): for any resource whose Limit is still unset after step 1, apply the LimitRange's Default. For any resource whose Request is still unset after step 1, apply DefaultRequest.

The consequence, verified against the k8s LimitRange memory walkthrough and the actual SetDefaults_Pod/mergeContainerResources source: a Limit that only becomes set via step 2's Default is never copied into a still-unset Request. Only an explicit Limit gets copied to Request (step 1); DefaultRequest is the only thing that can fill a Request left unset after step 1. It's tempting to assume "Limit ends up set -> Request defaults to it" applies regardless of how the Limit got set, but Kubernetes' ordering means that only holds for an explicit Limit. This is covered by a dedicated test case (see below).

Other rules implemented:

  • Only Container-typed LimitRangeItems apply to a container's own requests/limits; Pod/PersistentVolumeClaim-typed items are ignored.
  • A container's own explicit values are never touched -- a LimitRange only ever fills a gap.
  • cpu and memory (and in principle any resource name present in a LimitRangeItem) are resolved independently.
  • A resource that's still unset after both steps continues to render as "Not Set" -- this feature only fills gaps Kubernetes itself would fill; it doesn't fabricate values.

Design decision on multiple LimitRanges (the one place where "correct" isn't fully pinned down): the Kubernetes docs state plainly that "If two or more LimitRange objects exist in the namespace, it is not deterministic which default value will be applied." For display purposes, this PR approximates LimitRanger's actual "only fill what's still missing" mechanics applied across items in List order: the first applicable item supplies a given resource, later ones are ignored for that resource once it's filled. This is a best-effort, deterministic approximation for the dashboard, not a guarantee of matching live-pod behavior in a namespace with conflicting multiple LimitRanges -- called out in code comments in pkg/summary/limitrange.go.

Namespaces without a LimitRange (the common case)

LimitRanges(namespace).List(...) is called once per distinct namespace per summary generation (cached on the Summarizer for its lifetime), not once per container/workload/VPA, so this doesn't introduce an N+1. An empty list is a clean no-op -- verified requests/limits are byte-for-byte unchanged and the two existing full-summary regression tests (Test_Summarizer, Test_Summarizer_Daemonset) still pass unmodified against namespaces with no LimitRange.

Dashboard

ContainerSummary gained RequestsFromLimitRange/LimitsFromLimitRange (map[corev1.ResourceName]bool), populated only when a value came from a LimitRange (nil otherwise, so existing JSON/API consumers see no change for the common no-LimitRange case). pkg/dashboard/templates/container.gohtml shows a small "From LimitRange" badge next to any such value, following the same visual pattern as the existing "Matches Recommendation" badge (pkg/dashboard/templates/namespace.gohtml) -- same badge CSS structure, just a different color (--color-warning) to keep it visually distinct.

Validation performed

  • go build ./... -- clean
  • go vet ./... -- clean
  • gofmt -l on all changed/added .go files -- no output
  • golangci-lint run ./... (v2.12.0, locally installed; this repo has no checked-in .golangci.yml or lint CI workflow to pin a version against) -- 0 issues
  • go test ./... -- all packages pass, including the two new test files:
    • pkg/summary/limitrange_test.go: table-driven unit tests for the resolution function covering no-LimitRange no-op, only-Default, only-DefaultRequest, both set, explicit-values-untouched, cpu/memory resolved independently, Pod-typed items ignored, multiple LimitRange objects (first-applicable-wins), and the explicit-limit-copies-to-request-ignoring-DefaultRequest edge case described above. Also covers the namespace-level LimitRange listing/caching helper (empty namespace, filtering, and that repeated namespace lookups don't re-hit the fake API).
    • pkg/summary/summary_limitrange_test.go: a full GetSummary() integration test (fake clientsets, following this package's existing kube.GetMockClient()/GetMockVPAClient()/GetMockDynamicClient() conventions) with a container that sets no resources at all, verifying the end-to-end wiring.
  • Manual sanity check: temporarily rendered the real dashboard.Dashboard() HTTP handler against fixture data (fake clientsets, a LimitRange with Default memory + DefaultRequest cpu, a container with no explicit resources) and inspected the output HTML. Confirmed: CPU Request = 100m (badge shown), CPU Limit = Not Set (no badge, no Default configured), Memory Request = Not Set (no badge -- no DefaultRequest and nothing explicit to copy), Memory Limit = 512Mi (badge shown). This throwaway test file was not committed.

Notes

The issue's maintainer said he wasn't sure how hard this would be to implement correctly -- the main risk area was exactly the ordering edge case above (explicit-limit-only vs. Default-only vs. both), which I verified against the actual SetDefaults_Pod/LimitRanger source and the official walkthrough rather than assuming, since getting it backwards would make the feature actively misleading. The multi-LimitRange tie-break is the one place I made a judgment call rather than following a single documented "correct" behavior, since Kubernetes itself documents that case as non-deterministic.

@abhyudayareddy

Copy link
Copy Markdown
Contributor Author

cc @sudermanjr — this one builds on the same area as #875/#878, adding LimitRange support for #315. No rush, just making sure it's on your radar alongside the others.

…ndsOps#315)

Namespaces can define a LimitRange that supplies default cpu/memory
requests/limits for containers that don't set their own. Previously the
dashboard only looked at each container's own resources.Requests/Limits,
so any value filled in by a LimitRange showed up as "Not Set" even though
a real Pod created from that workload would have gotten an effective
value from Kubernetes.

GetSummary() now lists each namespace's LimitRange objects once (cached
per namespace, so this doesn't add a per-container API call) and resolves
the effective requests/limits per container, mirroring the two-step order
Kubernetes itself uses:

 1. SetDefaults_Pod: an explicit Limit with no explicit Request causes the
    Request to be defaulted to that Limit, before any admission plugin
    runs.
 2. LimitRanger admission: any resource whose Limit is still unset gets
    the LimitRange's Default; any resource whose Request is still unset
    gets DefaultRequest. Because step 1 already ran, a Limit that only
    becomes set here (via Default) is never copied into a still-unset
    Request.

A value that's still unset after both steps continues to show as "Not
Set" -- this only fills gaps Kubernetes itself would fill, it doesn't
fabricate values. Container-typed LimitRangeItems only; Pod/PVC-typed
items are ignored since they don't constrain an individual container.
Explicit values on a container are never touched.

ContainerSummary now also records, per resource name, whether a value
came from a LimitRange (RequestsFromLimitRange/LimitsFromLimitRange), and
the dashboard shows a small "From LimitRange" badge next to those values
so it's clear where they came from, following the same badge pattern
used for the "Matches Recommendation" indicator.
@abhyudayareddy
abhyudayareddy force-pushed the limitrange-support-315 branch from addf315 to 7b1ab52 Compare August 28, 2026 01:44
@abhyudayareddy

Copy link
Copy Markdown
Contributor Author

Rebased onto latest master (was 2 commits behind after #883 landed) — no conflicts, go test ./... green locally. Ready whenever you have a chance, @sudermanjr.

@abhyudayareddy

Copy link
Copy Markdown
Contributor Author

cc @sudermanjr — checking back in, this one's been quiet for a couple weeks (LimitRange defaults for current requests/limits, #315). Still rebased clean on latest master with no conflicts. Let me know if there's anything I can do to help move it forward. Thanks!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Support LimitRange for current settings.

1 participant