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
237 changes: 237 additions & 0 deletions pkg/controller/dynamic_filtering_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,237 @@
package controller

import (
"testing"

v1 "github.com/openshift-splat-team/vsphere-capacity-manager/pkg/apis/vspherecapacitymanager.splat.io/v1"
"github.com/openshift-splat-team/vsphere-capacity-manager/pkg/utils"
configv1 "github.com/openshift/api/config/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)

// TestDynamicVCenterFiltering tests the dynamic filtering logic that adapts
// based on remaining vCenter slots and remaining pools needed.
func TestDynamicVCenterFiltering(t *testing.T) {
tests := []struct {
name string
requiredPools int
vcentersLimit int
assignedPools int // How many pools already assigned
vcentersInUse int // How many distinct vCenters in use
availablePools []*v1.Pool
expectExclusions bool
expectedMinPoolsNeeded int // Expected minPoolsPerVCenter threshold
description string
}{
{
name: "cap reached - only allow vcenters in use",
requiredPools: 4,
vcentersLimit: 3,
assignedPools: 3, // Already have 3 pools
vcentersInUse: 3, // From 3 different vCenters (cap reached)
availablePools: []*v1.Pool{
createPool("vcenter-A", "pool-a", 100, 1000),
createPool("vcenter-B", "pool-b", 100, 1000),
createPool("vcenter-C", "pool-c", 100, 1000),
createPool("vcenter-D", "pool-d", 100, 1000), // Should be excluded
},
expectExclusions: true, // Should exclude vcenter-D
description: "Cap reached: should exclude all vCenters not in use",
},
{
name: "one slot left for two pools - require multi-pool vcenter",
requiredPools: 4,
vcentersLimit: 3,
assignedPools: 2, // Already have 2 pools
vcentersInUse: 2, // From 2 different vCenters
availablePools: []*v1.Pool{
// vcenter-A and B already in use (not counted here)
// vcenter-C has 1 pool
createPool("vcenter-C", "pool-c1", 100, 1000),
// vcenter-D has 2 pools
createPool("vcenter-D", "pool-d1", 100, 1000),
createPool("vcenter-D", "pool-d2", 100, 1000),
},
expectExclusions: true,
expectedMinPoolsNeeded: 2, // ceil(2 remaining pools / 1 remaining slot) = 2
description: "Need 2 pools from 1 slot: should exclude single-pool vCenters",
},
{
name: "two slots left for three pools - require 2 per vcenter",
requiredPools: 4,
vcentersLimit: 3,
assignedPools: 1, // Already have 1 pool
vcentersInUse: 1, // From 1 vCenter
availablePools: []*v1.Pool{
// vcenter-A already in use
// vcenter-B has 1 pool - should be excluded
createPool("vcenter-B", "pool-b1", 100, 1000),
// vcenter-C has 2 pools - should be allowed
createPool("vcenter-C", "pool-c1", 100, 1000),
createPool("vcenter-C", "pool-c2", 100, 1000),
// vcenter-D has 3 pools - should be allowed
createPool("vcenter-D", "pool-d1", 100, 1000),
createPool("vcenter-D", "pool-d2", 100, 1000),
createPool("vcenter-D", "pool-d3", 100, 1000),
},
expectExclusions: true,
expectedMinPoolsNeeded: 2, // ceil(3 remaining / 2 slots) = 2
description: "Need 3 pools from 2 slots: exclude vCenters with < 2 pools",
},
{
name: "plenty of slots - no dynamic filtering",
requiredPools: 4,
vcentersLimit: 5,
assignedPools: 1,
vcentersInUse: 1,
availablePools: []*v1.Pool{
// Remaining: 3 pools needed, 4 slots available
// 3 <= 4, so no dynamic filtering needed
createPool("vcenter-B", "pool-b1", 100, 1000),
createPool("vcenter-C", "pool-c1", 100, 1000),
createPool("vcenter-D", "pool-d1", 100, 1000),
},
expectExclusions: false,
description: "More slots than pools: no dynamic filtering needed",
},
{
name: "all remaining vcenters excluded by dynamic filter - should trigger recovery",
requiredPools: 4,
vcentersLimit: 3,
assignedPools: 1, // Already have 1 pool from vcenter-A
vcentersInUse: 1,
availablePools: []*v1.Pool{
// vcenter-A already in use
// Need 3 more pools, have 2 slots left
// minPoolsPerVCenter = ceil(3/2) = 2
// All remaining vCenters have only 1 pool → ALL excluded
createPool("vcenter-B", "pool-b1", 100, 1000), // 1 pool < 2
createPool("vcenter-C", "pool-c1", 100, 1000), // 1 pool < 2
createPool("vcenter-D", "pool-d1", 100, 1000), // 1 pool < 2
},
expectExclusions: true,
expectedMinPoolsNeeded: 2, // ceil(3/2) = 2
description: "All remaining vCenters excluded: should trigger deadlock recovery",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
lease := &v1.Lease{
ObjectMeta: metav1.ObjectMeta{
Name: "test-lease",
Namespace: "default",
},
Spec: v1.LeaseSpec{
VCpus: 24,
Memory: 96,
Pools: tt.requiredPools,
VCenters: tt.vcentersLimit,
},
}

// Simulate assigned pools (create dummy pools)
assignedPools := make([]*v1.Pool, tt.assignedPools)
vcentersInUse := make(map[string]bool)
for i := 0; i < tt.assignedPools; i++ {
// Use different vCenters to match vcentersInUse count
vcenterName := ""
if i < tt.vcentersInUse {
vcenterName = string(rune('A' + i)) // A, B, C, ...
} else {
// Reuse an earlier vCenter
vcenterName = "A"
}
assignedPools[i] = createPool("vcenter-"+vcenterName, "assigned-pool-"+string(rune('1'+i)), 100, 1000)
vcentersInUse["vcenter-"+vcenterName] = true
}

// Calculate what the controller would calculate
remainingSlots := tt.vcentersLimit - len(vcentersInUse)
remainingPools := tt.requiredPools - len(assignedPools)

t.Logf("%s", tt.description)
t.Logf("Remaining slots: %d, Remaining pools: %d", remainingSlots, remainingPools)

var excludedVCenters map[string]bool

// Replicate the controller's logic
if len(vcentersInUse) >= lease.Spec.VCenters {
// Cap reached
excludedVCenters = make(map[string]bool)
for _, p := range tt.availablePools {
if !vcentersInUse[p.Spec.Server] {
excludedVCenters[p.Spec.Server] = true
}
}
t.Logf("Cap reached - excluded %d vCenters", len(excludedVCenters))
} else if remainingSlots > 0 && remainingPools > remainingSlots {
// Dynamic filtering
minPoolsPerVCenter := (remainingPools-1)/remainingSlots + 1
t.Logf("Dynamic filtering: minPoolsPerVCenter = %d", minPoolsPerVCenter)

if tt.expectedMinPoolsNeeded > 0 && minPoolsPerVCenter != tt.expectedMinPoolsNeeded {
t.Errorf("Expected minPoolsPerVCenter=%d, got %d",
tt.expectedMinPoolsNeeded, minPoolsPerVCenter)
}

// Count pools per vCenter
fittingPools, _ := utils.GetFittingPools(lease, tt.availablePools, nil)
poolsPerVCenter := make(map[string]int)
for _, p := range fittingPools {
if !vcentersInUse[p.Spec.Server] {
poolsPerVCenter[p.Spec.Server]++
}
}

// Exclude vCenters with insufficient pools
excludedVCenters = make(map[string]bool)
for _, p := range tt.availablePools {
if !vcentersInUse[p.Spec.Server] {
if poolsPerVCenter[p.Spec.Server] < minPoolsPerVCenter {
excludedVCenters[p.Spec.Server] = true
}
}
}

t.Logf("Dynamic filter excluded %d vCenters (with < %d pools)",
len(excludedVCenters), minPoolsPerVCenter)

for server, count := range poolsPerVCenter {
excluded := excludedVCenters[server]
t.Logf(" %s: %d pools → excluded=%v", server, count, excluded)
}
}

// Verify expectations
if tt.expectExclusions && len(excludedVCenters) == 0 {
t.Error("Expected exclusions but got none")
}
if !tt.expectExclusions && len(excludedVCenters) > 0 {
t.Errorf("Expected no exclusions but got %d", len(excludedVCenters))
}
})
}
}

// Helper function to create a pool for testing
func createPool(vcenterServer, poolName string, vcpus, memory int) *v1.Pool {
return &v1.Pool{
ObjectMeta: metav1.ObjectMeta{
Name: poolName,
},
Spec: v1.PoolSpec{
FailureDomainSpec: v1.FailureDomainSpec{
VSpherePlatformFailureDomainSpec: configv1.VSpherePlatformFailureDomainSpec{
Server: vcenterServer,
},
},
VCpus: vcpus,
Memory: memory,
},
Status: v1.PoolStatus{
VCpusAvailable: vcpus,
MemoryAvailable: memory,
},
}
}
107 changes: 100 additions & 7 deletions pkg/controller/leases.go
Original file line number Diff line number Diff line change
Expand Up @@ -769,13 +769,19 @@ func (l *LeaseReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl
}
}

// Enforce the vCenters cap: if the lease specifies a maximum number of distinct
// vCenters and we have already reached that limit, restrict future pool assignments
// to the vCenters already in use.
// Enforce the vCenters cap with smart filtering:
// 1. If cap reached: only allow vCenters already in use
// 2. If approaching cap with remaining pools > remaining slots: require vCenters with multiple pools
// 3. Initial selection (no pools assigned): pre-filter to avoid low-capacity vCenters
var excludedVCenters map[string]bool
if lease.Spec.VCenters > 0 {
vcentersInUse := utils.GetVCentersInUse(assignedPools)
log.Printf("Lease %s has vcenters cap %d, currently using %d vcenters: %v", lease.Name, lease.Spec.VCenters, len(vcentersInUse), vcentersInUse)
remainingVCenterSlots := lease.Spec.VCenters - len(vcentersInUse)
remainingPools := requiredPools - len(assignedPools)

log.Printf("Lease %s: cap=%d, using=%d, remaining_slots=%d, remaining_pools=%d",
lease.Name, lease.Spec.VCenters, len(vcentersInUse), remainingVCenterSlots, remainingPools)

if len(vcentersInUse) >= lease.Spec.VCenters {
// Cap reached — only allow pools from vCenters already in use
excludedVCenters = make(map[string]bool)
Expand All @@ -785,6 +791,39 @@ func (l *LeaseReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl
excludedVCenters[srv] = true
}
}
log.Printf("Lease %s: vCenter cap reached, only allowing vCenters in use", lease.Name)
} else if remainingVCenterSlots > 0 && remainingPools > remainingVCenterSlots {
// We need multiple pools per remaining vCenter slot
// Apply dynamic filtering: exclude vCenters that don't have enough pools
minPoolsPerVCenter := (remainingPools-1)/remainingVCenterSlots + 1

log.Printf("Lease %s: need %d pools from %d remaining slots, min %d pools per vCenter required",
lease.Name, remainingPools, remainingVCenterSlots, minPoolsPerVCenter)

// Count fitting pools per vCenter
fittingPools, _ := utils.GetFittingPools(lease, availablePools, nil)
fittingPoolsPerVCenter := make(map[string]int)
for _, p := range fittingPools {
if p.Spec.Server != "" && !vcentersInUse[p.Spec.Server] {
fittingPoolsPerVCenter[p.Spec.Server]++
}
}

// Exclude vCenters (not already in use) that don't have enough pools
excludedVCenters = make(map[string]bool)
for _, p := range availablePools {
srv := p.Spec.Server
if srv != "" && !vcentersInUse[srv] {
if fittingPoolsPerVCenter[srv] < minPoolsPerVCenter {
excludedVCenters[srv] = true
}
}
}

if len(excludedVCenters) > 0 {
log.Printf("Lease %s: excluded %d vCenters with < %d pools (dynamic filtering)",
lease.Name, len(excludedVCenters), minPoolsPerVCenter)
}
} else if lease.Spec.VCenters < requiredPools && len(assignedPools) == 0 {
// Special case: if we need more pools than vCenters allowed (VCenters < Pools),
// and we haven't assigned any pools yet, we must ensure we only pick from
Expand Down Expand Up @@ -906,9 +945,63 @@ func (l *LeaseReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl
pool, err := utils.GetPoolWithStrategy(lease, availablePools, v1.RESOURCE_ALLOCATION_STRATEGY_UNDERUTILIZED, excludedVCenters)
if err != nil {
log.Printf("GetPoolWithStrategy error for lease %s: %v", lease.Name, err)
// If we already have some pools assigned, mark as partial
if len(assignedPools) > 0 {
log.Printf("lease %s needs %d pools but only %d available", lease.Name, requiredPools, len(assignedPools))

// If we already have some pools assigned but can't get more due to vCenter filtering constraints,
// we should release what we have and go back to PENDING to try again later with different pools
if len(assignedPools) > 0 && lease.Spec.VCenters > 0 {
vcentersInUse := utils.GetVCentersInUse(assignedPools)

// Check if we're stuck because of vCenter constraints:
// 1. Cap reached: using all allowed vCenters
// 2. Dynamic filtering: excluded remaining vCenters due to insufficient pool count
capReached := len(vcentersInUse) >= lease.Spec.VCenters
dynamicFilteringApplied := len(excludedVCenters) > 0 && !capReached

if capReached || dynamicFilteringApplied {
reason := "vCenter cap"
if dynamicFilteringApplied {
reason = "dynamic vCenter filtering"
}
log.Printf("Lease %s: stuck at PARTIAL due to %s - releasing %d assigned pools to retry",
lease.Name, reason, len(assignedPools))

// Remove all pool AND network owner references to release them
// Networks are tied to pools, so if we're releasing pools, we should also release their networks
// to avoid resource leaks (networks staying locked to a lease that no longer owns the pools)
newOwnerRefs := []metav1.OwnerReference{}
for _, ref := range lease.OwnerReferences {
if ref.Kind != "Pool" && ref.Kind != "Network" {
newOwnerRefs = append(newOwnerRefs, ref)
}
}
lease.OwnerReferences = newOwnerRefs

// First update the lease metadata (OwnerReferences)
if err := l.Client.Update(ctx, lease); err != nil {
log.Printf("Failed to update lease metadata (release pools): %v", err)
return ctrl.Result{}, err
}

// Then update the status (conditions)
conditions.Set(lease, conditions.FalseConditionWithReason(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

how many times around will we go around before we reset the condition?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the idea here is that we move lease back to pending and return. next reconcile loop will attempt again. this way if logic is still broke, we have a chance of the other pools getting resources to allow it to be fulfilled. If this is bad, i can change back.

v1.LeaseConditionTypeFulfilled,
v1.ReasonLeaseNoPool,
v1.ConditionSeverityWarning,
fmt.Sprintf("Released %d pools due to %s constraint, retrying", len(assignedPools), reason),
))

if err := l.Client.Status().Update(ctx, lease); err != nil {
log.Printf("Failed to update lease status (set PENDING): %v", err)
return ctrl.Result{}, err
}

updateLeaseMetrics()
log.Printf("lease %s released pools and is PENDING - requeuing in %v", lease.Name, LEASE_PENDING_RETRY_INTERVAL)
return ctrl.Result{RequeueAfter: LEASE_PENDING_RETRY_INTERVAL}, nil
}

// Otherwise just mark as partial (not vCenter filtering related)
log.Printf("lease %s needs %d pools but only %d available (insufficient pool resources, not vCenter filtering)", lease.Name, requiredPools, len(assignedPools))
break
}

Expand Down
Loading
Loading