diff --git a/internal/knowledge/extractor/plugins/compute/flavor_groups.go b/internal/knowledge/extractor/plugins/compute/flavor_groups.go index fd5d31b48..032aa7bdc 100644 --- a/internal/knowledge/extractor/plugins/compute/flavor_groups.go +++ b/internal/knowledge/extractor/plugins/compute/flavor_groups.go @@ -7,6 +7,7 @@ import ( _ "embed" "encoding/json" "errors" + "fmt" "sort" "github.com/cobaltcore-dev/cortex/internal/knowledge/extractor/plugins" @@ -46,9 +47,55 @@ type FlavorGroupFeature struct { RamCoreRatioMax *uint64 `json:"ramCoreRatioMax,omitempty"` } -// HasFixedRamCoreRatio returns true if all flavors in this group have the same RAM/core ratio. func (f *FlavorGroupFeature) HasFixedRamCoreRatio() bool { - return f.RamCoreRatio != nil + if f.RamCoreRatio == nil { + return false + } + if f.RamCoreRatioMin == nil && f.RamCoreRatioMax == nil { + return true + } + return f.RamCoreRatioMin != nil && f.RamCoreRatioMax != nil && + *f.RamCoreRatio == *f.RamCoreRatioMin && *f.RamCoreRatio == *f.RamCoreRatioMax +} + +func (f *FlavorGroupFeature) Validate() error { + hasRatio := f.RamCoreRatio != nil + hasMin := f.RamCoreRatioMin != nil + hasMax := f.RamCoreRatioMax != nil + + allThreeSame := hasRatio && hasMin && hasMax && + *f.RamCoreRatio == *f.RamCoreRatioMin && *f.RamCoreRatio == *f.RamCoreRatioMax + isFixed := (hasRatio && !hasMin && !hasMax) || allThreeSame + isVariable := !hasRatio && hasMin && hasMax + isNone := !hasRatio && !hasMin && !hasMax + + if !isFixed && !isVariable && !isNone { + return fmt.Errorf("flavor group %q has inconsistent ratio fields", f.Name) + } + if isVariable && *f.RamCoreRatioMin >= *f.RamCoreRatioMax { + return fmt.Errorf("flavor group %q: RamCoreRatioMin (%d) must be less than RamCoreRatioMax (%d)", f.Name, *f.RamCoreRatioMin, *f.RamCoreRatioMax) + } + if (isFixed || isVariable) && f.SmallestFlavor.MemoryMB == 0 { + return fmt.Errorf("flavor group %q: SmallestFlavor.MemoryMB must be non-zero", f.Name) + } + return nil +} + +// RAMUnitMiB returns MiB per one declared LIQUID RAM unit: +// fixed-ratio groups use slots (SmallestFlavor.MemoryMB MiB each); variable-ratio use GiB (1024 MiB). +func (f *FlavorGroupFeature) RAMUnitMiB() uint64 { + if f.HasFixedRamCoreRatio() && f.SmallestFlavor.MemoryMB > 0 { + return f.SmallestFlavor.MemoryMB + } + return 1024 +} + +func (f *FlavorGroupFeature) DeclaredUnitsToGiB(units int64) int64 { + return units * int64(f.RAMUnitMiB()) / 1024 //nolint:gosec +} + +func (f *FlavorGroupFeature) GiBToDeclaredUnits(gib int64) int64 { + return gib * 1024 / int64(f.RAMUnitMiB()) //nolint:gosec } // flavorRow represents a row from the SQL query. @@ -177,7 +224,7 @@ func (e *FlavorGroupExtractor) Extract() ([]plugins.Feature, error) { "ramCoreRatioMin", ramCoreRatioMin, "ramCoreRatioMax", ramCoreRatioMax) - features = append(features, FlavorGroupFeature{ + fg := FlavorGroupFeature{ Name: groupName, Flavors: flavors, LargestFlavor: largest, @@ -185,7 +232,12 @@ func (e *FlavorGroupExtractor) Extract() ([]plugins.Feature, error) { RamCoreRatio: ramCoreRatio, RamCoreRatioMin: ramCoreRatioMin, RamCoreRatioMax: ramCoreRatioMax, - }) + } + if err := fg.Validate(); err != nil { + flavorGroupLog.Error(err, "skipping flavor group with invalid data", "groupName", groupName) + continue + } + features = append(features, fg) } // Sort features by group name for consistent ordering diff --git a/internal/knowledge/extractor/plugins/compute/flavor_groups_test.go b/internal/knowledge/extractor/plugins/compute/flavor_groups_test.go index 3c68c4315..9b2b4dabd 100644 --- a/internal/knowledge/extractor/plugins/compute/flavor_groups_test.go +++ b/internal/knowledge/extractor/plugins/compute/flavor_groups_test.go @@ -389,3 +389,214 @@ func TestFlavorGroupExtractor_RamCoreRatio_FixedRatio(t *testing.T) { t.Errorf("expected RamCoreRatioMax=nil for fixed ratio, got %d", *fg.RamCoreRatioMax) } } + +func TestFlavorGroupFeature_Validate(t *testing.T) { + ratio := uint64(4096) + lo, hi := uint64(2048), uint64(8192) + tests := []struct { + name string + fg FlavorGroupFeature + wantErr bool + }{ + { + name: "valid: all nil (no ratio info)", + fg: FlavorGroupFeature{Name: "none"}, + wantErr: false, + }, + { + name: "valid: fixed — only RamCoreRatio set", + fg: FlavorGroupFeature{ + Name: "fixed", + RamCoreRatio: &ratio, + SmallestFlavor: FlavorInGroup{MemoryMB: 8192}, + }, + wantErr: false, + }, + { + name: "valid: fixed — all three set to same value", + fg: FlavorGroupFeature{ + Name: "fixed-all-same", + RamCoreRatio: &ratio, + RamCoreRatioMin: &ratio, + RamCoreRatioMax: &ratio, + SmallestFlavor: FlavorInGroup{MemoryMB: 8192}, + }, + wantErr: false, + }, + { + name: "valid: variable — Min < Max, SmallestFlavor set", + fg: FlavorGroupFeature{ + Name: "variable", + RamCoreRatioMin: &lo, + RamCoreRatioMax: &hi, + SmallestFlavor: FlavorInGroup{MemoryMB: 8192}, + }, + wantErr: false, + }, + { + name: "invalid: RamCoreRatio + Min/Max set with different values", + fg: FlavorGroupFeature{ + Name: "inconsistent", + RamCoreRatio: &ratio, + RamCoreRatioMin: &lo, + RamCoreRatioMax: &hi, + SmallestFlavor: FlavorInGroup{MemoryMB: 8192}, + }, + wantErr: true, + }, + { + name: "invalid: only RamCoreRatioMin set", + fg: FlavorGroupFeature{Name: "partial", RamCoreRatioMin: &ratio}, + wantErr: true, + }, + { + name: "invalid: only RamCoreRatioMax set", + fg: FlavorGroupFeature{Name: "partial", RamCoreRatioMax: &ratio}, + wantErr: true, + }, + { + name: "invalid: variable with Min > Max", + fg: FlavorGroupFeature{ + Name: "inverted", + RamCoreRatioMin: &hi, + RamCoreRatioMax: &lo, + SmallestFlavor: FlavorInGroup{MemoryMB: 8192}, + }, + wantErr: true, + }, + { + name: "invalid: variable with Min == Max (should be fixed)", + fg: FlavorGroupFeature{ + Name: "equal-range", + RamCoreRatioMin: &ratio, + RamCoreRatioMax: &ratio, + SmallestFlavor: FlavorInGroup{MemoryMB: 8192}, + }, + wantErr: true, + }, + { + name: "invalid: fixed with SmallestFlavor.MemoryMB == 0", + fg: FlavorGroupFeature{ + Name: "fixed-no-smallest", + RamCoreRatio: &ratio, + }, + wantErr: true, + }, + { + name: "invalid: variable with SmallestFlavor.MemoryMB == 0", + fg: FlavorGroupFeature{ + Name: "variable-no-smallest", + RamCoreRatioMin: &lo, + RamCoreRatioMax: &hi, + }, + wantErr: true, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + err := tc.fg.Validate() + if (err != nil) != tc.wantErr { + t.Errorf("Validate() error = %v, wantErr %v", err, tc.wantErr) + } + }) + } +} + +func TestFlavorGroupFeature_RAMUnitMiB(t *testing.T) { + ratio := uint64(4096) + tests := []struct { + name string + fg FlavorGroupFeature + want uint64 + }{ + { + name: "fixed-ratio returns SmallestFlavor.MemoryMB", + fg: FlavorGroupFeature{ + RamCoreRatio: &ratio, + SmallestFlavor: FlavorInGroup{MemoryMB: 2048}, + }, + want: 2048, + }, + { + name: "variable-ratio returns 1024", + fg: FlavorGroupFeature{ + RamCoreRatio: nil, + }, + want: 1024, + }, + { + name: "fixed-ratio (all three same) returns SmallestFlavor.MemoryMB", + fg: FlavorGroupFeature{ + RamCoreRatio: &ratio, + RamCoreRatioMin: &ratio, + RamCoreRatioMax: &ratio, + SmallestFlavor: FlavorInGroup{MemoryMB: 2048}, + }, + want: 2048, + }, + { + name: "RamCoreRatio set but MemoryMB zero falls back to 1024 (invalid data, safe fallback)", + fg: FlavorGroupFeature{ + RamCoreRatio: &ratio, + SmallestFlavor: FlavorInGroup{MemoryMB: 0}, + }, + want: 1024, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if got := tc.fg.RAMUnitMiB(); got != tc.want { + t.Errorf("RAMUnitMiB() = %d, want %d", got, tc.want) + } + }) + } +} + +func TestFlavorGroupFeature_UnitConversions(t *testing.T) { + ratio := uint64(4096) + tests := []struct { + name string + fg FlavorGroupFeature + units int64 + expectedGiB int64 + giB int64 + expectedUnits int64 + }{ + { + name: "fixed-ratio 2 GiB/slot: 5 slots → 10 GiB, 10 GiB → 5 slots", + fg: FlavorGroupFeature{ + RamCoreRatio: &ratio, + SmallestFlavor: FlavorInGroup{MemoryMB: 2048}, + }, + units: 5, expectedGiB: 10, + giB: 10, expectedUnits: 5, + }, + { + name: "variable-ratio (1 GiB/unit): 50 units → 50 GiB, 50 GiB → 50 units", + fg: FlavorGroupFeature{ + RamCoreRatio: nil, + }, + units: 50, expectedGiB: 50, + giB: 50, expectedUnits: 50, + }, + { + name: "fixed-ratio 1 GiB/slot (1024 MiB): conversion is a no-op", + fg: FlavorGroupFeature{ + RamCoreRatio: &ratio, + SmallestFlavor: FlavorInGroup{MemoryMB: 1024}, + }, + units: 100, expectedGiB: 100, + giB: 100, expectedUnits: 100, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if got := tc.fg.DeclaredUnitsToGiB(tc.units); got != tc.expectedGiB { + t.Errorf("DeclaredUnitsToGiB(%d) = %d, want %d", tc.units, got, tc.expectedGiB) + } + if got := tc.fg.GiBToDeclaredUnits(tc.giB); got != tc.expectedUnits { + t.Errorf("GiBToDeclaredUnits(%d) = %d, want %d", tc.giB, got, tc.expectedUnits) + } + }) + } +} diff --git a/internal/scheduling/reservations/commitments/api/change_commitments.go b/internal/scheduling/reservations/commitments/api/change_commitments.go index 25ccbff64..c3af765e8 100644 --- a/internal/scheduling/reservations/commitments/api/change_commitments.go +++ b/internal/scheduling/reservations/commitments/api/change_commitments.go @@ -203,6 +203,9 @@ ProcessLoop: break ProcessLoop } + groupData := flavorGroups[flavorGroupName] + ramUnitMiB := groupData.RAMUnitMiB() + groupResourceConf := api.config.ResourceConfigForGroup(flavorGroupName) var handlesCommitments bool switch resourceType { @@ -263,7 +266,7 @@ ProcessLoop: } stateDesired, err := commitments.FromChangeCommitmentTargetState( - commitment, string(projectID), domainID, flavorGroupName, resourceType, string(req.AZ)) + commitment, string(projectID), domainID, flavorGroupName, resourceType, string(req.AZ), ramUnitMiB) if err != nil { failedReason = fmt.Sprintf("commitment %s: %s", commitment.UUID, err) rollback = true diff --git a/internal/scheduling/reservations/commitments/api/info.go b/internal/scheduling/reservations/commitments/api/info.go index b1a4fd776..8f1a7b194 100644 --- a/internal/scheduling/reservations/commitments/api/info.go +++ b/internal/scheduling/reservations/commitments/api/info.go @@ -143,12 +143,19 @@ func (api *HTTPAPI) buildServiceInfo(ctx context.Context, logger logr.Logger) (l if resCfg.RAM.HandlesCommitments { ramTopology = liquid.AZSeparatedTopology } + // Fixed-ratio groups: unit is 1 slot (= 1 smallest-flavor instance); variable-ratio: GiB. + var ramUnit liquid.Unit + var ramDisplayName string + if groupData.HasFixedRamCoreRatio() { + ramUnit = liquid.UnitNone + ramDisplayName = fmt.Sprintf("multiples of %d MiB (usable by: %s)", groupData.SmallestFlavor.MemoryMB, flavorListStr) + } else { + ramUnit = liquid.UnitGibibytes + ramDisplayName = fmt.Sprintf("GiB of RAM (usable by: %s)", flavorListStr) + } resources[ramResourceName] = liquid.ResourceInfo{ - DisplayName: fmt.Sprintf( - "GiB of RAM (usable by: %s)", - flavorListStr, - ), - Unit: liquid.UnitGibibytes, + DisplayName: ramDisplayName, + Unit: ramUnit, Topology: ramTopology, NeedsResourceDemand: false, HasCapacity: resCfg.RAM.HasCapacity, diff --git a/internal/scheduling/reservations/commitments/api/info_test.go b/internal/scheduling/reservations/commitments/api/info_test.go index c7d946ddc..c01261f6c 100644 --- a/internal/scheduling/reservations/commitments/api/info_test.go +++ b/internal/scheduling/reservations/commitments/api/info_test.go @@ -340,6 +340,14 @@ func TestHandleInfo_ResourceFlagsFromConfig(t *testing.T) { 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))) + + // Verify RAM units: fixed-ratio groups use UnitNone (slot-based), variable-ratio use UnitGibibytes. + if ramResource.Unit != liquid.UnitNone { + t.Errorf("hw_version_hana_fixed_ram: expected Unit=%q (slot-based), got %q", liquid.UnitNone, ramResource.Unit) + } + if v2RamResource.Unit != liquid.UnitGibibytes { + t.Errorf("hw_version_v2_variable_ram: expected Unit=%q, got %q", liquid.UnitGibibytes, v2RamResource.Unit) + } } func TestHandleInfo_TopologyFollowsHandlesCommitments(t *testing.T) { diff --git a/internal/scheduling/reservations/commitments/api/quota.go b/internal/scheduling/reservations/commitments/api/quota.go index 4d6109a7b..167443e36 100644 --- a/internal/scheduling/reservations/commitments/api/quota.go +++ b/internal/scheduling/reservations/commitments/api/quota.go @@ -12,6 +12,8 @@ import ( "time" "github.com/cobaltcore-dev/cortex/api/v1alpha1" + "github.com/cobaltcore-dev/cortex/internal/scheduling/reservations" + commitments "github.com/cobaltcore-dev/cortex/internal/scheduling/reservations/commitments" "github.com/google/uuid" "github.com/sapcc/go-api-declarations/liquid" apierrors "k8s.io/apimachinery/pkg/api/errors" @@ -94,10 +96,26 @@ func (api *HTTPAPI) HandleQuota(w http.ResponseWriter, r *http.Request) { return } - // Build per-AZ quota maps from the liquid request. + // Fetch flavor groups to determine per-resource RAM unit. + // The ProjectQuota CRD stores RAM values in GiB; Limes sends in the declared unit + // (slots for fixed-ratio groups, GiB for variable-ratio). Convert on receipt. + knowledge := &reservations.FlavorGroupKnowledgeClient{Client: api.client} + flavorGroups, err := knowledge.GetAllFlavorGroups(r.Context(), nil) + if err != nil { + log.Info("flavor groups not available for quota unit conversion", "error", err.Error()) + api.quotaError(w, http.StatusServiceUnavailable, "flavor groups not available: "+err.Error(), startTime) + return + } + // ramResourceToGroup maps RAM resource name → group name for unit conversion. + ramResourceToGroup := make(map[string]string, len(flavorGroups)) + for groupName := range flavorGroups { + ramResourceToGroup[commitments.ResourceNameRAM(groupName)] = groupName + } + + // Build per-AZ quota maps from the liquid request, converting RAM to GiB. // liquid API uses uint64; our CRD uses int64 (K8s convention). // Guard against overflow: uint64 values > MaxInt64 would wrap to negative. - // quotaByAZ[az][resourceName] = quota value for that AZ + // quotaByAZ[az][resourceName] = quota in GiB for that AZ quotaByAZ := make(map[string]map[string]int64) for resourceName, resQuota := range req.Resources { for az, azQuota := range resQuota.PerAZ { @@ -105,11 +123,16 @@ func (api *HTTPAPI) HandleQuota(w http.ResponseWriter, r *http.Request) { api.quotaError(w, http.StatusBadRequest, fmt.Sprintf("Quota value for resource %q in AZ %q exceeds int64 max", resourceName, az), startTime) return } + quotaValue := int64(azQuota.Quota) + if groupName, ok := ramResourceToGroup[string(resourceName)]; ok { + fg := flavorGroups[groupName] + quotaValue = fg.DeclaredUnitsToGiB(quotaValue) + } azStr := string(az) if quotaByAZ[azStr] == nil { quotaByAZ[azStr] = make(map[string]int64) } - quotaByAZ[azStr][string(resourceName)] = int64(azQuota.Quota) + quotaByAZ[azStr][string(resourceName)] = quotaValue } } diff --git a/internal/scheduling/reservations/commitments/api/quota_test.go b/internal/scheduling/reservations/commitments/api/quota_test.go index 11ec744a4..074976289 100644 --- a/internal/scheduling/reservations/commitments/api/quota_test.go +++ b/internal/scheduling/reservations/commitments/api/quota_test.go @@ -146,15 +146,26 @@ func TestHandleQuota_ErrorCases(t *testing.T) { } } +// quotaTestKnowledge1GiB creates a Knowledge CRD for the hana_1 flavor group where +// SmallestFlavor.MemoryMB = 1024 MiB (1 GiB exactly). With this flavor the slot→GiB +// conversion is a no-op (1 slot = 1 GiB), so existing expected quota values are unchanged. +func quotaTestKnowledge1GiB(t *testing.T) *v1alpha1.Knowledge { + t.Helper() + return createKnowledgeCRD(buildFlavorGroupsKnowledge( + []*TestFlavor{{Name: "hana_c4_m1", Group: "hana_1", MemoryMB: 1024, VCPUs: 4}}, 1, + )) +} + func TestHandleQuota_CreateAndUpdate(t *testing.T) { tests := []struct { name string // existing is a set of pre-existing per-AZ CRDs to seed (nil = create, non-nil = update) existing []*v1alpha1.ProjectQuota + knowledge *v1alpha1.Knowledge // nil = use quotaTestKnowledge1GiB projectID string resources map[liquid.ResourceName]liquid.ResourceQuotaRequest metadata *liquid.ProjectMetadata - expectPerAZ map[string]map[string]int64 // az → resource name → expected quota + expectPerAZ map[string]map[string]int64 // az → resource name → expected GiB quota in CRD expectName string expectDom string expectDomName string @@ -330,7 +341,11 @@ func TestHandleQuota_CreateAndUpdate(t *testing.T) { } builder = builder.WithObjects(objs...) } - k8sClient := builder.Build() + knowledge := tc.knowledge + if knowledge == nil { + knowledge = quotaTestKnowledge1GiB(t) + } + k8sClient := builder.WithObjects(knowledge).Build() httpAPI := NewAPI(k8sClient) quotaReq := liquid.ServiceQuotaRequest{ @@ -399,3 +414,80 @@ func TestHandleQuota_CreateAndUpdate(t *testing.T) { func boolPtr(b bool) *bool { return &b } + +// TestHandleQuota_KnowledgeNotReady verifies that the quota endpoint returns 503 when +// the flavor-group Knowledge CRD is absent (needed for unit conversion). +func TestHandleQuota_KnowledgeNotReady(t *testing.T) { + scheme := newTestScheme(t) + // No Knowledge CRD — simulates startup before the extractor has run. + k8sClient := fake.NewClientBuilder().WithScheme(scheme).Build() + httpAPI := NewAPI(k8sClient) + + quotaReq := liquid.ServiceQuotaRequest{ + Resources: map[liquid.ResourceName]liquid.ResourceQuotaRequest{ + "hw_version_hana_1_ram": { + PerAZ: map[liquid.AvailabilityZone]liquid.AZResourceQuotaRequest{ + "az-a": {Quota: 10}, + }, + }, + }, + } + quotaReq.ProjectMetadata = option.Some(liquid.ProjectMetadata{ + UUID: "project-test", + Domain: liquid.DomainMetadata{UUID: "domain-1"}, + }) + body := marshalQuotaReq(t, quotaReq) + + req := httptest.NewRequest(http.MethodPut, "/commitments/v1/projects/project-test/quota", bytes.NewReader(body)) + w := httptest.NewRecorder() + httpAPI.HandleQuota(w, req) + + if w.Code != http.StatusServiceUnavailable { + t.Errorf("expected 503, got %d", w.Code) + } +} + +// TestHandleQuota_UnitConversion verifies that the quota handler converts incoming declared-unit +// values (slots for fixed-ratio groups) to GiB before writing to the ProjectQuota CRD. +func TestHandleQuota_UnitConversion(t *testing.T) { + scheme := newTestScheme(t) + + // hana_1 group: SmallestFlavor.MemoryMB = 2048 MiB (2 GiB per slot). + // Sending 5 slots → expect 5 * 2048 / 1024 = 10 GiB stored. + knowledge := createKnowledgeCRD(buildFlavorGroupsKnowledge( + []*TestFlavor{{Name: "hana_c4_m2", Group: "hana_1", MemoryMB: 2048, VCPUs: 4}}, 1, + )) + k8sClient := fake.NewClientBuilder().WithScheme(scheme).WithObjects(knowledge).Build() + httpAPI := NewAPI(k8sClient) + + quotaReq := liquid.ServiceQuotaRequest{ + Resources: map[liquid.ResourceName]liquid.ResourceQuotaRequest{ + "hw_version_hana_1_ram": { + PerAZ: map[liquid.AvailabilityZone]liquid.AZResourceQuotaRequest{ + "az-a": {Quota: 5}, // 5 slots × 2 GiB/slot = 10 GiB + }, + }, + }, + } + quotaReq.ProjectMetadata = option.Some(liquid.ProjectMetadata{ + UUID: "project-conv", + Domain: liquid.DomainMetadata{UUID: "domain-1"}, + }) + body := marshalQuotaReq(t, quotaReq) + + req := httptest.NewRequest(http.MethodPut, "/commitments/v1/projects/project-conv/quota", bytes.NewReader(body)) + w := httptest.NewRecorder() + httpAPI.HandleQuota(w, req) + + if w.Code != http.StatusNoContent { + t.Fatalf("expected 204, got %d: %s", w.Code, w.Body.String()) + } + + var pq v1alpha1.ProjectQuota + if err := k8sClient.Get(context.Background(), client.ObjectKey{Name: "quota-project-conv-az-a"}, &pq); err != nil { + t.Fatalf("failed to get ProjectQuota CRD: %v", err) + } + if got := pq.Spec.Quota["hw_version_hana_1_ram"]; got != 10 { + t.Errorf("expected stored quota=10 GiB, got %d", got) + } +} diff --git a/internal/scheduling/reservations/commitments/api/report_capacity_test.go b/internal/scheduling/reservations/commitments/api/report_capacity_test.go index 362f3000f..a35c285c8 100644 --- a/internal/scheduling/reservations/commitments/api/report_capacity_test.go +++ b/internal/scheduling/reservations/commitments/api/report_capacity_test.go @@ -307,15 +307,15 @@ func TestCapacityCalculator(t *testing.T) { if azReport == nil { t.Fatal("expected az-one entry") } - if azReport.Capacity != 32000 { - t.Errorf("expected capacity=32000, got %d", azReport.Capacity) + if azReport.Capacity != 1000 { + t.Errorf("expected capacity=1000, got %d", azReport.Capacity) } if !azReport.Usage.IsSome() { t.Fatal("expected usage to be set for Ready CRD") } - // usage = (capacity - placeable) * 32 GiB/slot = (1000 - 800) * 32 = 6400 - if usage := azReport.Usage.UnwrapOr(0); usage != 6400 { - t.Errorf("expected usage=6400 (200*32), got %d", usage) + // usage = (total - placeable) slots = (1000 - 800) = 200 slots + if usage := azReport.Usage.UnwrapOr(0); usage != 200 { + t.Errorf("expected usage=200 (200 slots), got %d", usage) } }) @@ -373,9 +373,9 @@ func TestCapacityCalculator(t *testing.T) { if azReport == nil { t.Fatal("expected az-one entry") } - // Stale CRD: last-known capacity is still reported (1000 slots * 32 GiB/slot) - if azReport.Capacity != 32000 { - t.Errorf("expected last-known capacity=32000 for stale CRD, got %d", azReport.Capacity) + // Stale CRD: last-known capacity is still reported (1000 slots) + if azReport.Capacity != 1000 { + t.Errorf("expected last-known capacity=1000 for stale CRD, got %d", azReport.Capacity) } // Stale CRD: usage must be absent (None) if azReport.Usage.IsSome() { diff --git a/internal/scheduling/reservations/commitments/capacity.go b/internal/scheduling/reservations/commitments/capacity.go index 623493484..b9782d3f0 100644 --- a/internal/scheduling/reservations/commitments/capacity.go +++ b/internal/scheduling/reservations/commitments/capacity.go @@ -116,7 +116,13 @@ func (c *CapacityCalculator) CalculateCapacity(ctx context.Context, req liquid.S } totalSlots := uint64(smallest.TotalCapacityVMSlots) //nolint:gosec // slot count from CRD, realistically bounded - ramEntry := &liquid.AZResourceCapacityReport{Capacity: totalSlots * memoryMBPerSlot / 1024} + var ramCapacity uint64 + if groupData.HasFixedRamCoreRatio() { + ramCapacity = totalSlots + } else { + ramCapacity = totalSlots * memoryMBPerSlot / 1024 + } + ramEntry := &liquid.AZResourceCapacityReport{Capacity: ramCapacity} coresEntry := &liquid.AZResourceCapacityReport{Capacity: totalSlots * vcpusPerSlot} instancesEntry := &liquid.AZResourceCapacityReport{Capacity: totalSlots} @@ -128,7 +134,11 @@ func (c *CapacityCalculator) CalculateCapacity(ctx context.Context, req liquid.S if totalSlots > placeableSlots { usedSlots = totalSlots - placeableSlots } - ramEntry.Usage = Some[uint64](usedSlots * memoryMBPerSlot / 1024) + if groupData.HasFixedRamCoreRatio() { + ramEntry.Usage = Some[uint64](usedSlots) + } else { + ramEntry.Usage = Some[uint64](usedSlots * memoryMBPerSlot / 1024) + } coresEntry.Usage = Some[uint64](usedSlots * vcpusPerSlot) instancesEntry.Usage = Some[uint64](usedSlots) } diff --git a/internal/scheduling/reservations/commitments/state.go b/internal/scheduling/reservations/commitments/state.go index 976334f9b..ba8d78861 100644 --- a/internal/scheduling/reservations/commitments/state.go +++ b/internal/scheduling/reservations/commitments/state.go @@ -184,6 +184,9 @@ func FromCommitment( } // FromChangeCommitmentTargetState converts LIQUID API request to CommitmentState. +// ramUnitMiB is the size of one external RAM unit in MiB: +// - fixed-ratio flavor groups: SmallestFlavor.MemoryMB (1 unit = 1 smallest-flavor slot) +// - variable-ratio flavor groups: 1024 (1 unit = 1 GiB) func FromChangeCommitmentTargetState( commitment liquid.Commitment, projectID string, @@ -191,6 +194,7 @@ func FromChangeCommitmentTargetState( flavorGroupName string, resourceType v1alpha1.CommittedResourceType, az string, + ramUnitMiB uint64, ) (*CommitmentState, error) { // Validate commitment UUID format commitmentUUID := string(commitment.UUID) @@ -246,9 +250,8 @@ func FromChangeCommitmentTargetState( case v1alpha1.CommittedResourceTypeCores: state.TotalCores = int64(amountMultiple) default: - // Amount represents GiB of RAM (1 GiB per unit) - const gibInBytes = int64(1) << 30 - state.TotalMemoryBytes = int64(amountMultiple) * gibInBytes + // Convert external unit to bytes: 1 unit = ramUnitMiB MiB + state.TotalMemoryBytes = int64(amountMultiple) * int64(ramUnitMiB) * (1 << 20) //nolint:gosec // bounded by quota limits } return state, nil diff --git a/internal/scheduling/reservations/commitments/usage.go b/internal/scheduling/reservations/commitments/usage.go index 9931cda9d..61ea40cac 100644 --- a/internal/scheduling/reservations/commitments/usage.go +++ b/internal/scheduling/reservations/commitments/usage.go @@ -100,7 +100,7 @@ type VMUsageInfo struct { AZ string Hypervisor string CreatedAt time.Time - UsageMultiple uint64 // RAM in GiB + UsageMultiple uint64 // RAM in the group's declared unit: slot count (fixed-ratio) or GiB (variable-ratio) } // UsageCalculator computes usage reports for Limes LIQUID API. @@ -306,12 +306,16 @@ func getProjectVMs( // Determine flavor group flavorGroup := flavorToGroup[row.FlavorName] - // Calculate usage in GiB (FlavorRAM is in MiB). - // Add 16 MiB before dividing: flavors reserve 16 MiB for video RAM (hw_video:ram_max_mb=16), - // so a nominal "2 GiB" flavor has 2032 MiB. Without the adjustment, integer division truncates. + // Compute usage in the unit declared by the info endpoint for this group: + // - fixed-ratio: slot count (FlavorRAM / smallest.MemoryMB); exact since flavors are integer multiples + // - variable-ratio or unknown: GiB, with +16 MiB to round up video-RAM-adjusted values var usageMultiple uint64 if row.FlavorRAM > 0 { - usageMultiple = (row.FlavorRAM + 16) / 1024 + if fg, ok := flavorGroups[flavorGroup]; ok && fg.HasFixedRamCoreRatio() { + usageMultiple = row.FlavorRAM / fg.SmallestFlavor.MemoryMB + } else { + usageMultiple = (row.FlavorRAM + 16) / 1024 + } } // Normalize AZ @@ -521,7 +525,9 @@ func (c *UsageCalculator) buildUsageResponse( if quotaByResourceAZ != nil { if azMap, ok := quotaByResourceAZ[string(ramResourceName)]; ok { if q, ok := azMap[string(az)]; ok { - quota = q + // CRD stores quota in GiB; convert to declared unit for Limes. + fg := flavorGroups[flavorGroupName] + quota = fg.GiBToDeclaredUnits(q) } } } diff --git a/internal/scheduling/reservations/flavor_groups.go b/internal/scheduling/reservations/flavor_groups.go index b6344630a..b2ce7d859 100644 --- a/internal/scheduling/reservations/flavor_groups.go +++ b/internal/scheduling/reservations/flavor_groups.go @@ -12,9 +12,12 @@ import ( "github.com/cobaltcore-dev/cortex/internal/knowledge/extractor/plugins/compute" "k8s.io/apimachinery/pkg/api/meta" "k8s.io/apimachinery/pkg/types" + ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/client" ) +var flavorGroupsLog = ctrl.Log.WithName("flavor_groups") + // FindFlavorInGroups searches all flavor groups for a flavor by name. // Returns the flavor group name and flavor details, or an error if the flavor // is not found in any group. @@ -78,9 +81,13 @@ func (c *FlavorGroupKnowledgeClient) GetAllFlavorGroups(ctx context.Context, kno return nil, fmt.Errorf("failed to unbox flavor group features: %w", err) } - // Build map for efficient lookups + // Build map for efficient lookups, skipping any groups that fail validation. flavorGroupMap := make(map[string]compute.FlavorGroupFeature, len(features)) for _, feature := range features { + if err := feature.Validate(); err != nil { + flavorGroupsLog.Error(err, "skipping invalid flavor group from Knowledge CRD") + continue + } flavorGroupMap[feature.Name] = feature }