diff --git a/internal/scheduling/reservations/commitments/api/info.go b/internal/scheduling/reservations/commitments/api/info.go index d8459c50b..b1a4fd776 100644 --- a/internal/scheduling/reservations/commitments/api/info.go +++ b/internal/scheduling/reservations/commitments/api/info.go @@ -7,6 +7,7 @@ import ( "context" "encoding/json" "fmt" + "math" "net/http" "strconv" "strings" @@ -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) @@ -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 { @@ -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, @@ -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, diff --git a/internal/scheduling/reservations/commitments/api/info_test.go b/internal/scheduling/reservations/commitments/api/info_test.go index e74964925..c7d946ddc 100644 --- a/internal/scheduling/reservations/commitments/api/info_test.go +++ b/internal/scheduling/reservations/commitments/api/info_test.go @@ -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) @@ -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", @@ -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") @@ -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") @@ -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) + } + } } diff --git a/internal/scheduling/reservations/commitments/api/report_capacity_test.go b/internal/scheduling/reservations/commitments/api/report_capacity_test.go index 638c060db..362f3000f 100644 --- a/internal/scheduling/reservations/commitments/api/report_capacity_test.go +++ b/internal/scheduling/reservations/commitments/api/report_capacity_test.go @@ -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). @@ -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). @@ -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). @@ -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) { @@ -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