Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions operator/api/v1alpha1/deployment_policy_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,16 @@ type DeploymentBudget struct {
Count *int `json:"count,omitempty"`
}

// StrategyType represents the type of deployment strategy
type StrategyType string

const (
StrategyTypeFixed StrategyType = "fixed"
StrategyTypeLinear StrategyType = "linear"
StrategyTypeExponential StrategyType = "exponential"
StrategyTypeUnknown StrategyType = "unknown"
)

const (
DefaultCompartmentName = "__default__"
)
Expand Down
101 changes: 101 additions & 0 deletions operator/internal/controller/cluster_state_v2.go
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,7 @@ type SkyhookNodes interface {
GetCompartments() map[string]*wrapper.Compartment
AddCompartment(name string, compartment *wrapper.Compartment)
AddCompartmentNode(name string, node wrapper.SkyhookNode)
AssignNodeToCompartment(node wrapper.SkyhookNode) (string, error)
}

var _ SkyhookNodes = &skyhookNodes{}
Expand Down Expand Up @@ -751,6 +752,106 @@ func (skyhook *skyhookNodes) AddCompartmentNode(name string, node wrapper.Skyhoo
skyhook.compartments[name].AddNode(node)
}

// compartmentMatch represents a compartment that matches a node
type compartmentMatch struct {
name string
strategyType v1alpha1.StrategyType
capacity int
}

// countMatchingNodes counts how many nodes from allNodes match the given selector
func (skyhook *skyhookNodes) countMatchingNodes(selector metav1.LabelSelector) (int, error) {
labelSelector, err := metav1.LabelSelectorAsSelector(&selector)
if err != nil {
return 0, err
}

count := 0
for _, node := range skyhook.nodes {
if labelSelector.Matches(labels.Set(node.GetNode().Labels)) {
count++
}
}
return count, nil
}

// AssignNodeToCompartment assigns a single node to the appropriate compartment using overlap resolution.
// When a node matches multiple compartments, it resolves using:
// 1. Strategy safety order: Fixed is safer than Linear, which is safer than Exponential
// 2. Tie-break on same strategy: Choose compartment with smaller effective ceiling (window)
// 3. Final tie-break: Lexicographically by compartment name for determinism
// Assignments are recalculated fresh on every reconcile based on current cluster state.
func (skyhook *skyhookNodes) AssignNodeToCompartment(node wrapper.SkyhookNode) (string, error) {
nodeLabels := labels.Set(node.GetNode().Labels)

matches := []compartmentMatch{}

// Collect all matching compartments (excluding default)
for _, compartment := range skyhook.compartments {
// Skip the default compartment - it's a fallback
if compartment.Name == v1alpha1.DefaultCompartmentName {
continue
}

selector, err := metav1.LabelSelectorAsSelector(&compartment.Selector)
if err != nil {
return "", fmt.Errorf("invalid selector for compartment %s: %w", compartment.Name, err)
}

if selector.Matches(nodeLabels) {
// Count how many nodes in total match this compartment's selector
matchedCount, err := skyhook.countMatchingNodes(compartment.Selector)
if err != nil {
return "", fmt.Errorf("error counting matching nodes for compartment %s: %w", compartment.Name, err)
}

// Ensure at least 1 node for capacity calculation
if matchedCount == 0 {
matchedCount = 1
}

stratType := wrapper.GetStrategyType(compartment.Strategy)
capacity := wrapper.ComputeEffectiveCapacity(compartment.Budget, matchedCount)

matches = append(matches, compartmentMatch{
name: compartment.Name,
strategyType: stratType,
capacity: capacity,
})
}
}

// No matches - assign to default
if len(matches) == 0 {
return v1alpha1.DefaultCompartmentName, nil
}

// Single match - return it
if len(matches) == 1 {
return matches[0].name, nil
}

// Multiple matches - apply overlap resolution
// Sort matches using the safety heuristic
sort.Slice(matches, func(i, j int) bool {
// 1. Strategy safety order: Fixed > Linear > Exponential
if matches[i].strategyType != matches[j].strategyType {
return wrapper.StrategyIsSafer(matches[i].strategyType, matches[j].strategyType)
}

// 2. Tie-break on same strategy: smaller window (capacity)
if matches[i].capacity != matches[j].capacity {
return matches[i].capacity < matches[j].capacity
}

// 3. Final tie-break: lexicographically by name for determinism
return matches[i].name < matches[j].name
})

// Return the safest compartment
return matches[0].name, nil
}

// cleanupNodeMap removes nodes from the given map that no longer exist in currentNodes
// Returns false if nodeMap is nil, otherwise returns true if any nodes were removed
func cleanupNodeMap[T any](nodeMap map[string]T, currentNodes map[string]struct{}) bool {
Expand Down
Loading