Skip to content
Merged
Show file tree
Hide file tree
Changes from 13 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
6 changes: 6 additions & 0 deletions api/v1alpha1/reservation_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,12 @@ type ReservationSpec struct {
const (
// ReservationConditionReady indicates whether the reservation is active and ready.
ReservationConditionReady = "Ready"

// ReservationConditionVMMisplaced indicates that one or more VMs have been detected
// on a host other than TargetHost (e.g. after a live migration), but the new host
// lacks sufficient capacity to accept the full reservation slot. The VM is tracked in
// its new location but TargetHost is not updated until capacity becomes available.
ReservationConditionVMMisplaced = "VMMisplaced"
)

// CommittedResourceReservationStatus defines the status fields specific to committed resource reservations.
Expand Down
69 changes: 69 additions & 0 deletions internal/scheduling/reservations/capacity_accounting.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,75 @@ import (
"github.com/cobaltcore-dev/cortex/api/v1alpha1"
)

// HostHasCapacityForReservation reports whether hv has sufficient remaining capacity to
// absorb res moving to it — i.e. whether the unfilled portion of res's slot fits alongside
// everything already committed on the host.
//
// 1. Start from EffectiveCapacity (or Capacity when EffectiveCapacity is nil).
// 2. Subtract hv.Status.Allocation (VMs physically running on this host).
// 3. For each other reservation assigned to this host (via Spec.TargetHost or Status.Host),
// subtract its UnusedReservationCapacity.
// 4. Check that the remainder is ≥ UnusedReservationCapacity(res): the unfilled portion of
// res's slot. Confirmed VMs in res already appear in hv.Status.Allocation (step 2), so
// comparing against the full slot would count them twice.
//
// res itself is excluded from step 3 to avoid subtracting its own block from free capacity.
// Returns false when the hypervisor has no capacity data.
func HostHasCapacityForReservation(allReservations []v1alpha1.Reservation, hv hv1.Hypervisor, res *v1alpha1.Reservation) bool {
effCap := hv.Status.EffectiveCapacity
if effCap == nil {
effCap = hv.Status.Capacity
}
if effCap == nil {
return false
}

free := make(map[hv1.ResourceName]resource.Quantity, len(effCap))
for rn, qty := range effCap {
free[rn] = qty.DeepCopy()
}

for rn, allocated := range hv.Status.Allocation {
if f, ok := free[rn]; ok {
f.Sub(allocated)
free[rn] = f
}
}

for i := range allReservations {
other := &allReservations[i]
if other.Name == res.Name {
continue
}
// Only block resources from reservations that target or are confirmed on this host.
targetsThisHost := other.Spec.TargetHost == hv.Name || other.Status.Host == hv.Name
if !targetsThisHost {
continue
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
for rn, block := range UnusedReservationCapacity(other, false) {
Comment thread
juliusclausnitzer marked this conversation as resolved.
Outdated
if f, ok := free[rn]; ok {
f.Sub(block)
free[rn] = f
}
}
}

zero := resource.Quantity{}
for rn, required := range UnusedReservationCapacity(res, false) {
remaining, ok := free[rn]
if !ok {
return false
}
if remaining.Cmp(zero) < 0 {
return false
}
if remaining.Cmp(required) < 0 {
return false
}
}
return true
}

// UnusedReservationCapacity returns the resources a Reservation should block on its host(s).
// This is the single source of truth used by both the capacity controller and
// filter_has_enough_capacity to ensure consistent accounting.
Expand Down
162 changes: 162 additions & 0 deletions internal/scheduling/reservations/capacity_accounting_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (

hv1 "github.com/cobaltcore-dev/openstack-hypervisor-operator/api/v1"
"k8s.io/apimachinery/pkg/api/resource"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"

"github.com/cobaltcore-dev/cortex/api/v1alpha1"
)
Expand Down Expand Up @@ -170,3 +171,164 @@ func TestUnusedReservationCapacity(t *testing.T) {
})
}
}

func TestHostHasCapacityForReservation(t *testing.T) {
gib := func(n int64) resource.Quantity { return *resource.NewQuantity(n*1024*1024*1024, resource.BinarySI) }
cpu := func(n int64) resource.Quantity { return *resource.NewQuantity(n, resource.DecimalSI) }

hvWithCapacity := func(name string, memGiB, cpuCores int64) hv1.Hypervisor {
return hv1.Hypervisor{
ObjectMeta: metav1.ObjectMeta{Name: name},
Status: hv1.HypervisorStatus{
EffectiveCapacity: map[hv1.ResourceName]resource.Quantity{
hv1.ResourceMemory: gib(memGiB),
hv1.ResourceCPU: cpu(cpuCores),
},
},
}
}

resWithSlot := func(name, targetHost string, memGiB, cpuCores int64) v1alpha1.Reservation {
return v1alpha1.Reservation{
ObjectMeta: metav1.ObjectMeta{Name: name},
Spec: v1alpha1.ReservationSpec{
Type: v1alpha1.ReservationTypeCommittedResource,
TargetHost: targetHost,
Resources: map[hv1.ResourceName]resource.Quantity{
hv1.ResourceMemory: gib(memGiB),
hv1.ResourceCPU: cpu(cpuCores),
},
},
Status: v1alpha1.ReservationStatus{Host: targetHost},
}
}

tests := []struct {
name string
hv hv1.Hypervisor
res *v1alpha1.Reservation
others []v1alpha1.Reservation
wantFits bool
}{
{
name: "empty host: slot fits easily",
hv: hvWithCapacity("host-new", 960, 80),
res: func() *v1alpha1.Reservation { r := resWithSlot("res-1", "host-old", 480, 40); return &r }(),
wantFits: true,
},
{
name: "host fully consumed by another reservation: no capacity",
hv: hvWithCapacity("host-new", 480, 40),
res: func() *v1alpha1.Reservation { r := resWithSlot("res-target", "host-old", 480, 40); return &r }(),
others: []v1alpha1.Reservation{
resWithSlot("res-blocker", "host-new", 480, 40),
},
wantFits: false,
},
{
name: "host partially consumed, enough room left",
hv: hvWithCapacity("host-new", 960, 80),
res: func() *v1alpha1.Reservation { r := resWithSlot("res-target", "host-old", 480, 40); return &r }(),
others: []v1alpha1.Reservation{
resWithSlot("res-blocker", "host-new", 480, 40),
},
wantFits: true,
},
{
name: "host partially consumed, exactly at boundary: fits",
hv: hvWithCapacity("host-new", 960, 80),
res: func() *v1alpha1.Reservation { r := resWithSlot("res-target", "host-old", 480, 40); return &r }(),
others: []v1alpha1.Reservation{
resWithSlot("res-blocker-a", "host-new", 240, 20),
resWithSlot("res-blocker-b", "host-new", 240, 20),
},
wantFits: true,
},
{
name: "host partially consumed, one resource short (CPU)",
hv: hvWithCapacity("host-new", 960, 60),
res: func() *v1alpha1.Reservation { r := resWithSlot("res-target", "host-old", 480, 40); return &r }(),
others: []v1alpha1.Reservation{
resWithSlot("res-blocker", "host-new", 480, 40),
},
// 960-480=480 memory OK, but 60-40=20 CPU < 40 required
wantFits: false,
},
{
name: "target reservation itself excluded from blocking calculation",
hv: hvWithCapacity("host-new", 480, 40),
res: func() *v1alpha1.Reservation { r := resWithSlot("res-target", "host-new", 480, 40); return &r }(),
others: []v1alpha1.Reservation{
// Same name as res — should be ignored
resWithSlot("res-target", "host-new", 480, 40),
},
wantFits: true,
},
{
name: "reservations on other hosts do not count",
hv: hvWithCapacity("host-new", 480, 40),
res: func() *v1alpha1.Reservation { r := resWithSlot("res-target", "host-old", 480, 40); return &r }(),
others: []v1alpha1.Reservation{
resWithSlot("res-on-other-host", "host-unrelated", 480, 40),
},
wantFits: true,
},
{
name: "hv with no capacity data: always false",
hv: hv1.Hypervisor{
ObjectMeta: metav1.ObjectMeta{Name: "host-nocap"},
},
res: func() *v1alpha1.Reservation { r := resWithSlot("res-target", "host-old", 480, 40); return &r }(),
wantFits: false,
},
{
name: "hv allocation already consumed memory: no room",
hv: hv1.Hypervisor{
ObjectMeta: metav1.ObjectMeta{Name: "host-new"},
Status: hv1.HypervisorStatus{
EffectiveCapacity: map[hv1.ResourceName]resource.Quantity{
hv1.ResourceMemory: gib(480),
hv1.ResourceCPU: cpu(40),
},
Allocation: map[hv1.ResourceName]resource.Quantity{
hv1.ResourceMemory: gib(100),
},
},
},
res: func() *v1alpha1.Reservation {
r := resWithSlot("res-target", "host-old", 480, 40)
return &r
}(),
wantFits: false, // 480-100 = 380 GiB remaining < 480 GiB slot (no confirmed VMs, so full slot is required)
},
{
name: "reservation targeting via Status.Host (not TargetHost) still blocks",
hv: hvWithCapacity("host-new", 480, 40),
res: func() *v1alpha1.Reservation { r := resWithSlot("res-target", "host-old", 480, 40); return &r }(),
others: []v1alpha1.Reservation{
// TargetHost empty but Status.Host = host-new
{
ObjectMeta: metav1.ObjectMeta{Name: "res-status-host"},
Spec: v1alpha1.ReservationSpec{
Type: v1alpha1.ReservationTypeCommittedResource,
Resources: map[hv1.ResourceName]resource.Quantity{
hv1.ResourceMemory: gib(480),
hv1.ResourceCPU: cpu(40),
},
},
Status: v1alpha1.ReservationStatus{Host: "host-new"},
},
},
wantFits: false,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := HostHasCapacityForReservation(tt.others, tt.hv, tt.res)
if got != tt.wantFits {
t.Errorf("HostHasCapacityForReservation() = %v, want %v", got, tt.wantFits)
}
})
}
}
Loading