Skip to content
Draft
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
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
68 changes: 68 additions & 0 deletions internal/scheduling/reservations/capacity_accounting.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,74 @@ import (
"github.com/cobaltcore-dev/cortex/api/v1alpha1"
)

// HostHasCapacityForReservation reports whether hv has sufficient remaining capacity to
// accommodate the full Spec.Resources slot of res.
//
// It uses the same accounting as the scheduler's filter_has_enough_capacity:
// 1. Start from EffectiveCapacity (or Capacity when EffectiveCapacity is nil).
// 2. Subtract hv.Status.Allocation (VMs already running on this host).
// 3. For each other reservation in allReservations that is assigned to this host
// (via Spec.TargetHost or Status.Host), subtract its UnusedReservationCapacity.
// 4. Check that the remainder is ≥ res.Spec.Resources for every resource.
//
// The target reservation itself (matched by name) is excluded from the blocking
// calculation so we don't double-count it.
// 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
}
for rn, block := range UnusedReservationCapacity(other, false) {
if f, ok := free[rn]; ok {
f.Sub(block)
free[rn] = f
}
}
}

zero := resource.Quantity{}
for rn, required := range res.Spec.Resources {
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 < 480 GiB 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
Loading