Resolve policy per pod and make it part of profile identity (#87) - #88
Merged
Conversation
policy.Resolver.Resolve served two callers with incompatible contracts. The
metrics collector and resource adjuster both called it with only a
WorkloadProfile's identity tuple, which produced two silent failures from one
cause.
Over-reach: an Input with no namespace listed ResourcePolicies with
client.InNamespace(""), which means *all namespaces*, and Resolve ranks
namespace-scoped above cluster-scoped before comparing priority. One
ResourcePolicy with a loose selector therefore governed recommendations and
resizes for every matching profile in the cluster, outranking policies that
explicitly set a higher priority.
Split resolution: a policy selecting on kinds, annotations, or
namespaces.include matched at admission (which has the pod) but could never
match in the controllers (which have no namespace, owner kind, or annotations).
Pods were admitted with one policy's values and resized toward another's, with
nothing logging a conflict because each path resolved successfully on its own
terms.
Policy is now resolved once per pod by the workloadwatcher, the only component
holding a pod and therefore the only one that can evaluate a selector
correctly. The result is recorded on workloadprofile.status.policyRef; the
collector and adjuster load it instead of resolving again, so all three paths
agree by construction. An Input with no namespace can no longer match a
ResourcePolicy at all.
The governing policy is now part of profile identity, because a profile holds
one set of recommendations per container and the policy chooses the sources,
cadence, tracked resources, aggregation, and headroom that produce them.
Profile names carry a policy token, and each profile owns a Redis key namespace
via status.measurementHash, so sibling profiles sharing a tuple never write
into one sample series and the finalizer purges only its own keys. The policy
is deliberately kept out of tupleLabels: that map also produces
status.selectorLabels, the server-side query that finds a profile's pods, and a
policy name is not a pod label.
Both policy kinds are now watched, so a policy applied to a running cluster
takes effect without pod churn. Only selector and priority changes re-scope
identity; every other spec field is read live on the next cycle, so tuning a
policy does not re-key measurement history.
Upgrade impact: every existing WorkloadProfile is replaced by a newly-named one
and the fleet accrues from zero for one readiness.minTimeSpan before resizes
resume. Old profiles orphan, age out over orphanTTL, and their history is
purged by the finalizer.
Docker Image Builtdocker pull ghcr.io/tight-line/ballast:pr-88-f2464d7Helm (values override)image:
repository: ghcr.io/tight-line/ballast
tag: "pr-88-f2464d7"Image expires ~15 days after PR closes. |
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
The verification snippet named a profile 'billing--api--prod', which no longer resolves now that a profile's name carries a token for the policy governing it; following it verbatim gets a NotFound. Show listing the profiles first instead of hardcoding a name, so the snippet survives the next naming change too. Also qualified the cluster-scoped pooling claim: sharing an identity tuple is no longer sufficient to share a profile, since pods resolving to different policies are measured separately.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #87.
The defect
policy.Resolver.Resolveserved two callers with incompatible contracts. The metrics collector and resource adjuster both called it with only aWorkloadProfile's identity tuple:A
WorkloadProfileis cluster-scoped and pools pods across namespaces, so that Input has no namespace, no owner kind, and no annotations. Two silent failures followed from one cause:Over-reach.
client.InNamespace("")means all namespaces, andResolveranks namespace-scoped above cluster-scoped before it compares priority. OneResourcePolicywith a loose selector therefore governed recommendations and resizes for every matching profile in the cluster, outranking policies that explicitly set a higher priority.Split resolution. A policy selecting on
kinds,annotations, ornamespaces.includematched at admission (which has the pod) but could never match in the controllers. Pods were admitted with one policy's values and then resized toward another's, with nothing logging a conflict because each path resolved successfully on its own terms.The fix
Policy is resolved once per pod, by the workloadwatcher — the only component holding a pod, and therefore the only one that can evaluate a selector correctly. The result is recorded on
workloadprofile.status.policyRef; the collector and adjusterLoadit instead of resolving again, so all three paths agree by construction. AResolverInputwith no namespace can no longer match aResourcePolicyat all.The governing policy is now part of profile identity. A profile holds one set of recommendations per container, and the policy chooses the metrics sources, poll cadence, tracked resources, aggregation, and headroom that produce them, so pods resolving to different policies cannot share a profile. Two keys that were one are now separate:
status.policyRef. Names carry a policy token:checkout--server--fleet-a1b2c3d4.status.measurementHash, covering tuple and policy.Per-policy measurement namespaces (rather than a shared series) mean sibling profiles never
RPUSHinto one list. Samples carry no per-sample timestamps, so shared writes would inflate counts, distort the distribution, and halve the effective retention window. It also lets the finalizer purge its own keys with no reference counting.The policy is deliberately not folded into
tupleLabels. That map also producesstatus.selectorLabels, the server-side label query that finds a profile's pods, and a policy name is not a pod label — folding it in would rename profiles without changing which pods they measure.Runtime policy changes
Nothing watched either policy kind before. That was survivable only because resolution was never cached; caching it on the profile makes a watch mandatory. Both kinds are now watched, so a policy applied to a running cluster takes effect in seconds.
Only selector and priority changes re-scope identity. Every other spec field (aggregation, headroom, thresholds, sources, cadence) is read live on the next collection or resize cycle, so tuning a policy does not re-key measurement history: the sample already recorded does not change meaning because the headroom applied to it did.
The fan-out is deliberately indiscriminate (every enrolled pod). The affected set is not "the pods this policy matches" — on a delete the matching spec no longer exists, on a narrowed selector the affected pods are the ones that stopped matching, and a new high-priority policy flips pods that previously matched nothing. Each reconcile is a cache read plus a resolve and writes nothing unless identity actually changed.
Traceability
status.profileDiscriminatoron both policy kinds, with aDISCRIMINATORprint column, so profile names trace back to their policy without computing a hash by hand:The token hashes kind + namespace + name, not the name alone: two
ResourcePoliciesnameddefaultsin different namespaces are different policies, and a name-only hash would give them one token and make two distinct profiles contend for one object name. It is published on status rather than as a label because it is operator-derived data about a user-owned object.workloadprofilesalso gains aPOLICYcolumn, and the workloadwatcher now refreshes the pod'spolicy-refannotation, which previously kept advertising whatever admission resolved.Upgrade impact
Every existing
WorkloadProfileis replaced by a newly-named one, and the fleet accrues from zero for onereadiness.minTimeSpan(24h by default) before resizes resume. No resizes are issued in that window and admission applies nothing, so pods keep the requests they were created with. Old profiles orphan, age out overorphanTTL(168h), and their Redis history is purged by the finalizer as they go — the finalizer falls back to the bare tuple hash for profiles that predatemeasurementHash, so nothing is stranded. Nothing needs doing by hand.Notes on scope
The issue proposed moving
Resolveinto the resource adjuster's per-pod loop. That is now unnecessary: pods in a profile share a policy by construction, so reading the profile'spolicyRefis exactly per-pod resolution, done once.The issue's "annotations cannot scope measurement" consequence is now documented on the
PolicySelectorCRD type rather than left implicit.Verification
TestResolve_NoNamespace_ExcludesResourcePolicies(three cases, including the higher-priorityResourcePolicy), and passes.make lint— 0 issues.make test-coverage-check— passes at 79.1%.make helm-lint— passes.