@@ -180,6 +180,7 @@ type SkyhookNodes interface {
180180 GetCompartments () map [string ]* wrapper.Compartment
181181 AddCompartment (name string , compartment * wrapper.Compartment )
182182 AddCompartmentNode (name string , node wrapper.SkyhookNode )
183+ AssignNodeToCompartment (node wrapper.SkyhookNode ) (string , error )
183184}
184185
185186var _ SkyhookNodes = & skyhookNodes {}
@@ -751,6 +752,106 @@ func (skyhook *skyhookNodes) AddCompartmentNode(name string, node wrapper.Skyhoo
751752 skyhook .compartments [name ].AddNode (node )
752753}
753754
755+ // compartmentMatch represents a compartment that matches a node
756+ type compartmentMatch struct {
757+ name string
758+ strategyType v1alpha1.StrategyType
759+ capacity int
760+ }
761+
762+ // countMatchingNodes counts how many nodes from allNodes match the given selector
763+ func (skyhook * skyhookNodes ) countMatchingNodes (selector metav1.LabelSelector ) (int , error ) {
764+ labelSelector , err := metav1 .LabelSelectorAsSelector (& selector )
765+ if err != nil {
766+ return 0 , err
767+ }
768+
769+ count := 0
770+ for _ , node := range skyhook .nodes {
771+ if labelSelector .Matches (labels .Set (node .GetNode ().Labels )) {
772+ count ++
773+ }
774+ }
775+ return count , nil
776+ }
777+
778+ // AssignNodeToCompartment assigns a single node to the appropriate compartment using overlap resolution.
779+ // When a node matches multiple compartments, it resolves using:
780+ // 1. Strategy safety order: Fixed is safer than Linear, which is safer than Exponential
781+ // 2. Tie-break on same strategy: Choose compartment with smaller effective ceiling (window)
782+ // 3. Final tie-break: Lexicographically by compartment name for determinism
783+ // Assignments are recalculated fresh on every reconcile based on current cluster state.
784+ func (skyhook * skyhookNodes ) AssignNodeToCompartment (node wrapper.SkyhookNode ) (string , error ) {
785+ nodeLabels := labels .Set (node .GetNode ().Labels )
786+
787+ matches := []compartmentMatch {}
788+
789+ // Collect all matching compartments (excluding default)
790+ for _ , compartment := range skyhook .compartments {
791+ // Skip the default compartment - it's a fallback
792+ if compartment .Name == v1alpha1 .DefaultCompartmentName {
793+ continue
794+ }
795+
796+ selector , err := metav1 .LabelSelectorAsSelector (& compartment .Selector )
797+ if err != nil {
798+ return "" , fmt .Errorf ("invalid selector for compartment %s: %w" , compartment .Name , err )
799+ }
800+
801+ if selector .Matches (nodeLabels ) {
802+ // Count how many nodes in total match this compartment's selector
803+ matchedCount , err := skyhook .countMatchingNodes (compartment .Selector )
804+ if err != nil {
805+ return "" , fmt .Errorf ("error counting matching nodes for compartment %s: %w" , compartment .Name , err )
806+ }
807+
808+ // Ensure at least 1 node for capacity calculation
809+ if matchedCount == 0 {
810+ matchedCount = 1
811+ }
812+
813+ stratType := wrapper .GetStrategyType (compartment .Strategy )
814+ capacity := wrapper .ComputeEffectiveCapacity (compartment .Budget , matchedCount )
815+
816+ matches = append (matches , compartmentMatch {
817+ name : compartment .Name ,
818+ strategyType : stratType ,
819+ capacity : capacity ,
820+ })
821+ }
822+ }
823+
824+ // No matches - assign to default
825+ if len (matches ) == 0 {
826+ return v1alpha1 .DefaultCompartmentName , nil
827+ }
828+
829+ // Single match - return it
830+ if len (matches ) == 1 {
831+ return matches [0 ].name , nil
832+ }
833+
834+ // Multiple matches - apply overlap resolution
835+ // Sort matches using the safety heuristic
836+ sort .Slice (matches , func (i , j int ) bool {
837+ // 1. Strategy safety order: Fixed > Linear > Exponential
838+ if matches [i ].strategyType != matches [j ].strategyType {
839+ return wrapper .StrategyIsSafer (matches [i ].strategyType , matches [j ].strategyType )
840+ }
841+
842+ // 2. Tie-break on same strategy: smaller window (capacity)
843+ if matches [i ].capacity != matches [j ].capacity {
844+ return matches [i ].capacity < matches [j ].capacity
845+ }
846+
847+ // 3. Final tie-break: lexicographically by name for determinism
848+ return matches [i ].name < matches [j ].name
849+ })
850+
851+ // Return the safest compartment
852+ return matches [0 ].name , nil
853+ }
854+
754855// cleanupNodeMap removes nodes from the given map that no longer exist in currentNodes
755856// Returns false if nodeMap is nil, otherwise returns true if any nodes were removed
756857func cleanupNodeMap [T any ](nodeMap map [string ]T , currentNodes map [string ]struct {}) bool {
0 commit comments