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
33 changes: 27 additions & 6 deletions internal/scheduling/reservations/commitments/api/info.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"context"
"encoding/json"
"fmt"
"math"
"net/http"
"strconv"
"strings"
Expand Down Expand Up @@ -75,12 +76,22 @@ func (api *HTTPAPI) recordInfoMetrics(statusCode int, startTime time.Time) {
}

// resourceAttributes holds the custom attributes for a resource in the info API response.
// Ratio values are in GiB per vCPU, matching the RAM resource unit (UnitGibibytes).
type resourceAttributes struct {
RamCoreRatio *uint64 `json:"ramCoreRatio,omitempty"`
RamCoreRatioMin *uint64 `json:"ramCoreRatioMin,omitempty"`
RamCoreRatioMax *uint64 `json:"ramCoreRatioMax,omitempty"`
}

// mibToGiB converts a MiB pointer value to GiB, rounded to the nearest integer. Returns nil if v is nil.
func mibToGiB(v *uint64) *uint64 {
if v == nil {
return nil
}
gib := uint64(math.Round(float64(*v) / 1024))
return &gib
}

// buildServiceInfo constructs the ServiceInfo response with metadata for all flavor groups.
// For each flavor group, three resources are registered:
// - _ram: RAM resource (unit = multiples of smallest flavor RAM, HandlesCommitments=true only if fixed ratio)
Expand Down Expand Up @@ -110,11 +121,13 @@ func (api *HTTPAPI) buildServiceInfo(ctx context.Context, logger logr.Logger) (l
}
flavorListStr := strings.Join(flavorNames, ", ")

// Build attributes JSON with ratio info (shared across all resource types)
// Build attributes JSON with ratio info (shared across all resource types).
// Ratios are stored in MiB/vCPU in the knowledge CRD; convert to GiB/vCPU here
// so the values match the GiB unit used by the RAM resource.
attrs := resourceAttributes{
RamCoreRatio: groupData.RamCoreRatio,
RamCoreRatioMin: groupData.RamCoreRatioMin,
RamCoreRatioMax: groupData.RamCoreRatioMax,
RamCoreRatio: mibToGiB(groupData.RamCoreRatio),
RamCoreRatioMin: mibToGiB(groupData.RamCoreRatioMin),
RamCoreRatioMax: mibToGiB(groupData.RamCoreRatioMax),
}
attrsJSON, err := json.Marshal(attrs)
if err != nil {
Expand Down Expand Up @@ -146,13 +159,17 @@ func (api *HTTPAPI) buildServiceInfo(ctx context.Context, logger logr.Logger) (l

// === 2. Cores Resource ===
coresResourceName := liquid.ResourceName(commitments.ResourceNameCores(groupName))
coresTopology := liquid.AZAwareTopology
if resCfg.Cores.HandlesCommitments {
coresTopology = liquid.AZSeparatedTopology
}
resources[coresResourceName] = liquid.ResourceInfo{
DisplayName: fmt.Sprintf(
"CPU cores (usable by: %s)",
flavorListStr,
),
Unit: liquid.UnitNone,
Topology: liquid.AZAwareTopology,
Topology: coresTopology,
NeedsResourceDemand: false,
HasCapacity: resCfg.Cores.HasCapacity,
HasQuota: resCfg.Cores.HasQuota,
Expand All @@ -162,13 +179,17 @@ func (api *HTTPAPI) buildServiceInfo(ctx context.Context, logger logr.Logger) (l

// === 3. Instances Resource ===
instancesResourceName := liquid.ResourceName(commitments.ResourceNameInstances(groupName))
instancesTopology := liquid.AZAwareTopology
if resCfg.Instances.HandlesCommitments {
instancesTopology = liquid.AZSeparatedTopology
}
resources[instancesResourceName] = liquid.ResourceInfo{
DisplayName: fmt.Sprintf(
"instances (usable by: %s)",
flavorListStr,
),
Unit: liquid.UnitNone,
Topology: liquid.AZAwareTopology,
Topology: instancesTopology,
NeedsResourceDemand: false,
HasCapacity: resCfg.Instances.HasCapacity,
HasQuota: resCfg.Instances.HasQuota,
Expand Down
128 changes: 123 additions & 5 deletions internal/scheduling/reservations/commitments/api/info_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -138,8 +138,6 @@ func TestHandleInfo_InvalidFlavorMemory(t *testing.T) {
}

func TestHandleInfo_ResourceFlagsFromConfig(t *testing.T) {
// Test that resource flags (HandlesCommitments, HasCapacity, HasQuota) are read from config,
// not derived from flavor group metadata. Both groups get resources regardless of ratio.
scheme := runtime.NewScheme()
if err := v1alpha1.AddToScheme(scheme); err != nil {
t.Fatalf("failed to add scheme: %v", err)
Expand All @@ -155,7 +153,9 @@ func TestHandleInfo_ResourceFlagsFromConfig(t *testing.T) {
},
"largestFlavor": map[string]interface{}{"name": "hana_c8_m32", "vcpus": 8, "memoryMB": 32768, "diskGB": 100},
"smallestFlavor": map[string]interface{}{"name": "hana_c4_m16", "vcpus": 4, "memoryMB": 16384, "diskGB": 50},
"ramCoreRatio": 4096,
// 4094 MiB/vCPU simulates real flavor RAM (4096 MiB nominal − 2 MiB video RAM).
// Truncating division gives 3; rounding gives 4.
"ramCoreRatio": 4094,
},
{
"name": "v2_variable",
Expand Down Expand Up @@ -247,7 +247,7 @@ func TestHandleInfo_ResourceFlagsFromConfig(t *testing.T) {
t.Error("hw_version_hana_fixed_ram: expected HasQuota=true (fixed ratio groups accept quotas)")
}

// Test Cores resource: hw_version_hana_fixed_cores (always AZAwareTopology, no quota)
// Test Cores resource: hw_version_hana_fixed_cores (HandlesCommitments=false → AZAwareTopology)
coresResource, ok := serviceInfo.Resources["hw_version_hana_fixed_cores"]
if !ok {
t.Fatal("expected hw_version_hana_fixed_cores resource to exist")
Expand All @@ -265,7 +265,7 @@ func TestHandleInfo_ResourceFlagsFromConfig(t *testing.T) {
t.Error("hw_version_hana_fixed_cores: expected HasQuota=false")
}

// Test Instances resource: hw_version_hana_fixed_instances (always AZAwareTopology, no quota)
// Test Instances resource: hw_version_hana_fixed_instances (HandlesCommitments=false → AZAwareTopology)
instancesResource, ok := serviceInfo.Resources["hw_version_hana_fixed_instances"]
if !ok {
t.Fatal("expected hw_version_hana_fixed_instances resource to exist")
Expand Down Expand Up @@ -334,4 +334,122 @@ func TestHandleInfo_ResourceFlagsFromConfig(t *testing.T) {
if v2InstancesResource.HasQuota {
t.Error("hw_version_v2_variable_instances: expected HasQuota=false")
}

// Verify ratio attributes are converted from MiB to GiB.
// hana_fixed has ramCoreRatio=4096 MiB/vCPU → expect 4 GiB/vCPU.
checkAttrsRatio(t, "hw_version_hana_fixed_ram", ramResource.Attributes, 4, nil, nil)
// v2_variable has ramCoreRatioMin=2048 MiB/vCPU, ramCoreRatioMax=16384 MiB/vCPU → expect 2, 16 GiB/vCPU.
checkAttrsRatio(t, "hw_version_v2_variable_ram", v2RamResource.Attributes, 0, ptr(uint64(2)), ptr(uint64(16)))
}

func TestHandleInfo_TopologyFollowsHandlesCommitments(t *testing.T) {
// Verifies that any resource with HandlesCommitments=true gets AZSeparatedTopology,
// regardless of whether it's RAM, Cores, or Instances.
scheme := runtime.NewScheme()
if err := v1alpha1.AddToScheme(scheme); err != nil {
t.Fatalf("failed to add scheme: %v", err)
}

features := []map[string]interface{}{
{
"name": "fg",
"flavors": []map[string]interface{}{{"name": "fg_c4_m16", "vcpus": 4, "memoryMB": 16384, "diskGB": 50}},
"largestFlavor": map[string]interface{}{"name": "fg_c4_m16", "vcpus": 4, "memoryMB": 16384, "diskGB": 50},
"smallestFlavor": map[string]interface{}{"name": "fg_c4_m16", "vcpus": 4, "memoryMB": 16384, "diskGB": 50},
"ramCoreRatio": 4096,
},
}
raw, err := v1alpha1.BoxFeatureList(features)
if err != nil {
t.Fatalf("failed to box features: %v", err)
}
knowledge := &v1alpha1.Knowledge{
ObjectMeta: v1.ObjectMeta{Name: "flavor-groups"},
Spec: v1alpha1.KnowledgeSpec{
SchedulingDomain: v1alpha1.SchedulingDomainNova,
Extractor: v1alpha1.KnowledgeExtractorSpec{Name: "flavor_groups"},
},
Status: v1alpha1.KnowledgeStatus{
Conditions: []v1.Condition{{Type: v1alpha1.KnowledgeConditionReady, Status: "True"}},
Raw: raw,
LastContentChange: v1.Now(),
},
}

// All three resource types handle commitments.
cfg := commitments.DefaultAPIConfig()
cfg.FlavorGroupResourceConfig = map[string]commitments.FlavorGroupResourcesConfig{
"fg": {
RAM: commitments.ResourceTypeConfig{HandlesCommitments: true, HasCapacity: true, HasQuota: true},
Cores: commitments.ResourceTypeConfig{HandlesCommitments: true, HasCapacity: true, HasQuota: true},
Instances: commitments.ResourceTypeConfig{HandlesCommitments: true, HasCapacity: true, HasQuota: true},
},
}

k8sClient := fake.NewClientBuilder().WithScheme(scheme).WithObjects(knowledge).Build()
api := NewAPIWithConfig(k8sClient, cfg, nil)

req := httptest.NewRequest(http.MethodGet, "/commitments/v1/info", http.NoBody)
w := httptest.NewRecorder()
api.HandleInfo(w, req)

resp := w.Result()
defer resp.Body.Close()

if resp.StatusCode != http.StatusOK {
t.Fatalf("expected 200, got %d", resp.StatusCode)
}

var serviceInfo liquid.ServiceInfo
if err := json.NewDecoder(resp.Body).Decode(&serviceInfo); err != nil {
t.Fatalf("failed to decode: %v", err)
}

for _, resName := range []string{"hw_version_fg_ram", "hw_version_fg_cores", "hw_version_fg_instances"} {
res, ok := serviceInfo.Resources[liquid.ResourceName(resName)]
if !ok {
t.Fatalf("expected resource %s", resName)
}
if res.Topology != liquid.AZSeparatedTopology {
t.Errorf("%s: expected Topology=%q (HandlesCommitments=true), got %q", resName, liquid.AZSeparatedTopology, res.Topology)
}
}
}

// ptr returns a pointer to v, for use in test assertions.
func ptr[T any](v T) *T { return &v }

// checkAttrsRatio decodes the Attributes JSON of a resource and verifies the ratio fields
// are in GiB/vCPU. Pass ratioGiB=0 to skip the fixed-ratio check; pass nil min/max to skip range checks.
func checkAttrsRatio(t *testing.T, resName string, raw json.RawMessage, ratioGiB uint64, minGiB, maxGiB *uint64) {
t.Helper()
var attrs struct {
RamCoreRatio *uint64 `json:"ramCoreRatio"`
RamCoreRatioMin *uint64 `json:"ramCoreRatioMin"`
RamCoreRatioMax *uint64 `json:"ramCoreRatioMax"`
}
if err := json.Unmarshal(raw, &attrs); err != nil {
t.Fatalf("%s: failed to decode attributes: %v", resName, err)
}
if ratioGiB != 0 {
if attrs.RamCoreRatio == nil {
t.Errorf("%s: expected ramCoreRatio=%d GiB/vCPU, got nil", resName, ratioGiB)
} else if *attrs.RamCoreRatio != ratioGiB {
t.Errorf("%s: expected ramCoreRatio=%d GiB/vCPU, got %d", resName, ratioGiB, *attrs.RamCoreRatio)
}
}
if minGiB != nil {
if attrs.RamCoreRatioMin == nil {
t.Errorf("%s: expected ramCoreRatioMin=%d GiB/vCPU, got nil", resName, *minGiB)
} else if *attrs.RamCoreRatioMin != *minGiB {
t.Errorf("%s: expected ramCoreRatioMin=%d GiB/vCPU, got %d", resName, *minGiB, *attrs.RamCoreRatioMin)
}
}
if maxGiB != nil {
if attrs.RamCoreRatioMax == nil {
t.Errorf("%s: expected ramCoreRatioMax=%d GiB/vCPU, got nil", resName, *maxGiB)
} else if *attrs.RamCoreRatioMax != *maxGiB {
t.Errorf("%s: expected ramCoreRatioMax=%d GiB/vCPU, got %d", resName, *maxGiB, *attrs.RamCoreRatioMax)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -285,7 +285,7 @@ func TestCapacityCalculator(t *testing.T) {

t.Run("CalculateCapacity reads capacity and usage from Ready CRD", func(t *testing.T) {
knowledge := createTestFlavorGroupKnowledge(t)
crd := createTestFlavorGroupCapacity("test-group", "az-one", "test_c8_m32", 1000, 800, true)
crd := createTestFlavorGroupCapacity(1000, 800, true)
fakeClient := fake.NewClientBuilder().
WithScheme(scheme).
WithObjects(knowledge, crd).
Expand Down Expand Up @@ -322,7 +322,7 @@ func TestCapacityCalculator(t *testing.T) {
t.Run("CalculateCapacity returns zero capacity for missing CRD", func(t *testing.T) {
knowledge := createTestFlavorGroupKnowledge(t)
// CRD exists only for az-one; az-two has no CRD
crd := createTestFlavorGroupCapacity("test-group", "az-one", "test_c8_m32", 500, 400, true)
crd := createTestFlavorGroupCapacity(500, 400, true)
fakeClient := fake.NewClientBuilder().
WithScheme(scheme).
WithObjects(knowledge, crd).
Expand Down Expand Up @@ -351,7 +351,7 @@ func TestCapacityCalculator(t *testing.T) {

t.Run("CalculateCapacity omits usage for stale CRD (Ready=False)", func(t *testing.T) {
knowledge := createTestFlavorGroupKnowledge(t)
crd := createTestFlavorGroupCapacity("test-group", "az-one", "test_c8_m32", 1000, 800, false)
crd := createTestFlavorGroupCapacity(1000, 800, false)
fakeClient := fake.NewClientBuilder().
WithScheme(scheme).
WithObjects(knowledge, crd).
Expand Down Expand Up @@ -382,9 +382,41 @@ func TestCapacityCalculator(t *testing.T) {
t.Error("expected usage to be absent (None) for stale CRD")
}
})

t.Run("CalculateCapacity omits resources with HasCapacity=false", func(t *testing.T) {
knowledge := createTestFlavorGroupKnowledge(t)
crd := createTestFlavorGroupCapacity(100, 80, true)
fakeClient := fake.NewClientBuilder().
WithScheme(scheme).
WithObjects(knowledge, crd).
WithStatusSubresource(crd).
Build()

// Only RAM and Cores have capacity; Instances does not.
cfgNoInstances := commitments.APIConfig{
FlavorGroupResourceConfig: map[string]commitments.FlavorGroupResourcesConfig{
"*": {
RAM: commitments.ResourceTypeConfig{HasCapacity: true},
Cores: commitments.ResourceTypeConfig{HasCapacity: true},
Instances: commitments.ResourceTypeConfig{HasCapacity: false},
},
},
}
calculator := commitments.NewCapacityCalculator(fakeClient, cfgNoInstances)
req := liquid.ServiceCapacityRequest{AllAZs: []liquid.AvailabilityZone{"az-one"}}
report, err := calculator.CalculateCapacity(context.Background(), req)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(report.Resources) != 2 {
t.Fatalf("expected 2 resources (ram, cores), got %d: %v", len(report.Resources), report.Resources)
}
if _, ok := report.Resources["hw_version_test-group_instances"]; ok {
t.Error("expected hw_version_test-group_instances to be absent (HasCapacity=false)")
}
})
}

// verifyPerAZMatchesRequest checks that perAZ entries match exactly the requested AZs.
// This follows the same semantics as nova liquid: the response must contain
// entries for all AZs in AllAZs, no more and no less.
func verifyPerAZMatchesRequest(t *testing.T, res *liquid.ResourceCapacityReport, requestedAZs []liquid.AvailabilityZone) {
Expand Down Expand Up @@ -443,7 +475,10 @@ func createEmptyFlavorGroupKnowledge() *v1alpha1.Knowledge {
// createTestFlavorGroupCapacity creates a FlavorGroupCapacity CRD for testing.
// totalSlots and placeableSlots are for the named smallest flavor entry.
// ready controls whether the Ready condition is True or False.
func createTestFlavorGroupCapacity(group, az, smallestFlavorName string, totalSlots, placeableSlots int64, ready bool) *v1alpha1.FlavorGroupCapacity {
func createTestFlavorGroupCapacity(totalSlots, placeableSlots int64, ready bool) *v1alpha1.FlavorGroupCapacity {
const group = "test-group"
const az = "az-one"
const smallestFlavorName = "test_c8_m32"
conditionStatus := v1.ConditionTrue
if !ready {
conditionStatus = v1.ConditionFalse
Expand Down