diff --git a/internal/knowledge/extractor/plugins/compute/flavor_groups.go b/internal/knowledge/extractor/plugins/compute/flavor_groups.go index d5c47cf2a..1dacb9d38 100644 --- a/internal/knowledge/extractor/plugins/compute/flavor_groups.go +++ b/internal/knowledge/extractor/plugins/compute/flavor_groups.go @@ -37,6 +37,18 @@ type FlavorGroupFeature struct { // The smallest flavor in the group (used for CR size quantification) SmallestFlavor FlavorInGroup `json:"smallestFlavor"` + + // RAM-to-core ratio in MiB per vCPU (MemoryMB / VCPUs). + // If all flavors have the same ratio: RamCoreRatio is set, RamCoreRatioMin/Max are nil. + // If flavors have different ratios: RamCoreRatio is nil, RamCoreRatioMin/Max are set. + RamCoreRatio *uint64 `json:"ramCoreRatio,omitempty"` + RamCoreRatioMin *uint64 `json:"ramCoreRatioMin,omitempty"` + 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 } // flavorRow represents a row from the SQL query. @@ -128,6 +140,31 @@ func (e *FlavorGroupExtractor) Extract() ([]plugins.Feature, error) { largest := flavors[0] smallest := flavors[len(flavors)-1] + // Compute RAM/core ratio (MiB per vCPU) + var minRatio, maxRatio uint64 = ^uint64(0), 0 + for _, f := range flavors { + if f.VCPUs == 0 { + continue // Skip flavors with 0 vCPUs to avoid division by zero + } + ratio := f.MemoryMB / f.VCPUs + if ratio < minRatio { + minRatio = ratio + } + if ratio > maxRatio { + maxRatio = ratio + } + } + + var ramCoreRatio, ramCoreRatioMin, ramCoreRatioMax *uint64 + if minRatio == maxRatio && maxRatio != 0 { + // All flavors have the same ratio + ramCoreRatio = &minRatio + } else if maxRatio != 0 { + // Flavors have different ratios + ramCoreRatioMin = &minRatio + ramCoreRatioMax = &maxRatio + } + flavorGroupLog.Info("identified largest and smallest flavors", "groupName", groupName, "largestFlavor", largest.Name, @@ -135,13 +172,19 @@ func (e *FlavorGroupExtractor) Extract() ([]plugins.Feature, error) { "largestVCPUs", largest.VCPUs, "smallestFlavor", smallest.Name, "smallestMemoryMB", smallest.MemoryMB, - "smallestVCPUs", smallest.VCPUs) + "smallestVCPUs", smallest.VCPUs, + "ramCoreRatio", ramCoreRatio, + "ramCoreRatioMin", ramCoreRatioMin, + "ramCoreRatioMax", ramCoreRatioMax) features = append(features, FlavorGroupFeature{ - Name: groupName, - Flavors: flavors, - LargestFlavor: largest, - SmallestFlavor: smallest, + Name: groupName, + Flavors: flavors, + LargestFlavor: largest, + SmallestFlavor: smallest, + RamCoreRatio: ramCoreRatio, + RamCoreRatioMin: ramCoreRatioMin, + RamCoreRatioMax: ramCoreRatioMax, }) } diff --git a/internal/knowledge/extractor/plugins/compute/flavor_groups_test.go b/internal/knowledge/extractor/plugins/compute/flavor_groups_test.go index becccadd0..3c68c4315 100644 --- a/internal/knowledge/extractor/plugins/compute/flavor_groups_test.go +++ b/internal/knowledge/extractor/plugins/compute/flavor_groups_test.go @@ -270,4 +270,122 @@ func TestFlavorGroupExtractor_Extract(t *testing.T) { if !foundCH { t.Error("Cloud-Hypervisor flavor should have been included but was not found") } + + // Verify RAM/core ratio for v2 group + // v2 group has flavors with different ratios: + // - hana flavors: 491520/30=16384, 983040/60=16384, 3932160/240=16384 MiB/vCPU + // - gp_c8_m32_v2: 32768/8=4096, gp_c16_m64_v2: 65536/16=4096 MiB/vCPU + // - gp_c12_m32_v2: 32768/12=2730, gp_c12_m32_alt: 32768/12=2730 MiB/vCPU + // So min=2730, max=16384 (variable ratio) + if v2Group.RamCoreRatio != nil { + t.Errorf("expected v2 group to have variable ratio (nil RamCoreRatio), got %d", *v2Group.RamCoreRatio) + } + if v2Group.RamCoreRatioMin == nil || *v2Group.RamCoreRatioMin != 2730 { + var got any = nil + if v2Group.RamCoreRatioMin != nil { + got = *v2Group.RamCoreRatioMin + } + t.Errorf("expected v2 group RamCoreRatioMin=2730, got %v", got) + } + if v2Group.RamCoreRatioMax == nil || *v2Group.RamCoreRatioMax != 16384 { + var got any = nil + if v2Group.RamCoreRatioMax != nil { + got = *v2Group.RamCoreRatioMax + } + t.Errorf("expected v2 group RamCoreRatioMax=16384, got %v", got) + } + + // Verify RAM/core ratio for ch group (single flavor = fixed ratio) + // gp_c4_m16_ch: 16384/4=4096 MiB/vCPU + if chGroup.RamCoreRatio == nil || *chGroup.RamCoreRatio != 4096 { + var got any = nil + if chGroup.RamCoreRatio != nil { + got = *chGroup.RamCoreRatio + } + t.Errorf("expected ch group RamCoreRatio=4096, got %v", got) + } + if chGroup.RamCoreRatioMin != nil { + t.Errorf("expected ch group RamCoreRatioMin=nil (fixed ratio), got %d", *chGroup.RamCoreRatioMin) + } + if chGroup.RamCoreRatioMax != nil { + t.Errorf("expected ch group RamCoreRatioMax=nil (fixed ratio), got %d", *chGroup.RamCoreRatioMax) + } +} + +func TestFlavorGroupExtractor_RamCoreRatio_FixedRatio(t *testing.T) { + dbEnv := testlibDB.SetupDBEnv(t) + defer dbEnv.Close() + testDB := db.DB{DbMap: dbEnv.DbMap} + + if err := testDB.CreateTable( + testDB.AddTable(nova.Flavor{}), + ); err != nil { + t.Fatal(err) + } + + // Insert flavors with same RAM/core ratio (4096 MiB/vCPU) + flavors := []any{ + &nova.Flavor{ + ID: "1", + Name: "fixed_c2_m8", + VCPUs: 2, + RAM: 8192, // 8GB + Disk: 50, + ExtraSpecs: `{"capabilities:hypervisor_type":"qemu","quota:hw_version":"fixed"}`, + }, + &nova.Flavor{ + ID: "2", + Name: "fixed_c4_m16", + VCPUs: 4, + RAM: 16384, // 16GB + Disk: 50, + ExtraSpecs: `{"capabilities:hypervisor_type":"qemu","quota:hw_version":"fixed"}`, + }, + &nova.Flavor{ + ID: "3", + Name: "fixed_c8_m32", + VCPUs: 8, + RAM: 32768, // 32GB + Disk: 50, + ExtraSpecs: `{"capabilities:hypervisor_type":"qemu","quota:hw_version":"fixed"}`, + }, + } + + if err := testDB.Insert(flavors...); err != nil { + t.Fatal(err) + } + + extractor := &FlavorGroupExtractor{} + if err := extractor.Init(&testDB, nil, v1alpha1.KnowledgeSpec{}); err != nil { + t.Fatal(err) + } + + features, err := extractor.Extract() + if err != nil { + t.Fatal(err) + } + + if len(features) != 1 { + t.Fatalf("expected 1 flavor group, got %d", len(features)) + } + + fg := features[0].(FlavorGroupFeature) + if fg.Name != "fixed" { + t.Errorf("expected group name 'fixed', got %s", fg.Name) + } + + // All flavors have ratio 4096 MiB/vCPU + if fg.RamCoreRatio == nil || *fg.RamCoreRatio != 4096 { + var got any = nil + if fg.RamCoreRatio != nil { + got = *fg.RamCoreRatio + } + t.Errorf("expected RamCoreRatio=4096, got %v", got) + } + if fg.RamCoreRatioMin != nil { + t.Errorf("expected RamCoreRatioMin=nil for fixed ratio, got %d", *fg.RamCoreRatioMin) + } + if fg.RamCoreRatioMax != nil { + t.Errorf("expected RamCoreRatioMax=nil for fixed ratio, got %d", *fg.RamCoreRatioMax) + } } diff --git a/internal/scheduling/reservations/commitments/api.go b/internal/scheduling/reservations/commitments/api.go index 49e1ee261..eadaca37d 100644 --- a/internal/scheduling/reservations/commitments/api.go +++ b/internal/scheduling/reservations/commitments/api.go @@ -35,6 +35,6 @@ func NewAPIWithConfig(client client.Client, config Config) *HTTPAPI { func (api *HTTPAPI) Init(mux *http.ServeMux, registry prometheus.Registerer) { registry.MustRegister(&api.monitor) mux.HandleFunc("/v1/commitments/change-commitments", api.HandleChangeCommitments) - // mux.HandleFunc("/v1/report-capacity", api.HandleReportCapacity) + mux.HandleFunc("/v1/commitments/report-capacity", api.HandleReportCapacity) mux.HandleFunc("/v1/commitments/info", api.HandleInfo) } diff --git a/internal/scheduling/reservations/commitments/api_change_commitments.go b/internal/scheduling/reservations/commitments/api_change_commitments.go index 2488d5843..1ef003e30 100644 --- a/internal/scheduling/reservations/commitments/api_change_commitments.go +++ b/internal/scheduling/reservations/commitments/api_change_commitments.go @@ -186,6 +186,13 @@ ProcessLoop: break ProcessLoop } + // Reject commitments for flavor groups that don't accept CRs + if !FlavorGroupAcceptsCommitments(&flavorGroup) { + resp.RejectionReason = FlavorGroupCommitmentRejectionReason(&flavorGroup) + requireRollback = true + break ProcessLoop + } + for _, commitment := range resourceChanges.Commitments { // Additional per-commitment validation if needed logger.Info("processing commitment change", "commitmentUUID", commitment.UUID, "projectID", projectID, "resourceName", resourceName, "oldStatus", commitment.OldStatus.UnwrapOr("none"), "newStatus", commitment.NewStatus.UnwrapOr("none")) diff --git a/internal/scheduling/reservations/commitments/api_change_commitments_test.go b/internal/scheduling/reservations/commitments/api_change_commitments_test.go index 4d68227a1..bf0a8cf8f 100644 --- a/internal/scheduling/reservations/commitments/api_change_commitments_test.go +++ b/internal/scheduling/reservations/commitments/api_change_commitments_test.go @@ -629,11 +629,37 @@ func (tfg TestFlavorGroup) ToFlavorGroupsKnowledge() FlavorGroupsKnowledge { smallest := groupFlavors[len(groupFlavors)-1] largest := groupFlavors[0] + // Compute RAM/core ratio (MiB per vCPU) + var minRatio, maxRatio uint64 = ^uint64(0), 0 + for _, f := range groupFlavors { + if f.VCPUs == 0 { + continue + } + ratio := f.MemoryMB / f.VCPUs + if ratio < minRatio { + minRatio = ratio + } + if ratio > maxRatio { + maxRatio = ratio + } + } + + var ramCoreRatio, ramCoreRatioMin, ramCoreRatioMax *uint64 + if minRatio == maxRatio && maxRatio != 0 { + ramCoreRatio = &minRatio + } else if maxRatio != 0 { + ramCoreRatioMin = &minRatio + ramCoreRatioMax = &maxRatio + } + groups = append(groups, compute.FlavorGroupFeature{ - Name: groupName, - Flavors: groupFlavors, - SmallestFlavor: smallest, - LargestFlavor: largest, + Name: groupName, + Flavors: groupFlavors, + SmallestFlavor: smallest, + LargestFlavor: largest, + RamCoreRatio: ramCoreRatio, + RamCoreRatioMin: ramCoreRatioMin, + RamCoreRatioMax: ramCoreRatioMax, }) } diff --git a/internal/scheduling/reservations/commitments/api_info.go b/internal/scheduling/reservations/commitments/api_info.go index 140dc54d4..c7dfe9cd4 100644 --- a/internal/scheduling/reservations/commitments/api_info.go +++ b/internal/scheduling/reservations/commitments/api_info.go @@ -49,6 +49,13 @@ func (api *HTTPAPI) HandleInfo(w http.ResponseWriter, r *http.Request) { } } +// resourceAttributes holds the custom attributes for a resource in the info API response. +type resourceAttributes struct { + RamCoreRatio *uint64 `json:"ramCoreRatio,omitempty"` + RamCoreRatioMin *uint64 `json:"ramCoreRatioMin,omitempty"` + RamCoreRatioMax *uint64 `json:"ramCoreRatioMax,omitempty"` +} + // buildServiceInfo constructs the ServiceInfo response with metadata for all flavor groups. func (api *HTTPAPI) buildServiceInfo(ctx context.Context, logger logr.Logger) (liquid.ServiceInfo, error) { // Get all flavor groups from Knowledge CRDs @@ -77,14 +84,30 @@ func (api *HTTPAPI) buildServiceInfo(ctx context.Context, logger logr.Logger) (l strings.Join(flavorNames, ", "), ) + // Only handle commitments for groups with a fixed RAM/core ratio + handlesCommitments := FlavorGroupAcceptsCommitments(&groupData) + + // Build attributes JSON with ratio info + attrs := resourceAttributes{ + RamCoreRatio: groupData.RamCoreRatio, + RamCoreRatioMin: groupData.RamCoreRatioMin, + RamCoreRatioMax: groupData.RamCoreRatioMax, + } + attrsJSON, err := json.Marshal(attrs) + if err != nil { + logger.Error(err, "failed to marshal resource attributes", "resourceName", resourceName) + attrsJSON = nil + } + resources[resourceName] = liquid.ResourceInfo{ DisplayName: displayName, Unit: liquid.UnitNone, // Countable: multiples of smallest flavor instances Topology: liquid.AZAwareTopology, // Commitments are per-AZ NeedsResourceDemand: false, // Capacity planning out of scope for now - HasCapacity: true, // We report capacity via /v1/report-capacity + HasCapacity: handlesCommitments, // We report capacity via /v1/report-capacity only for groups that accept commitments HasQuota: false, // No quota enforcement as of now - HandlesCommitments: true, // We handle commitment changes via /v1/change-commitments + HandlesCommitments: handlesCommitments, // Only for groups with fixed RAM/core ratio + Attributes: attrsJSON, } logger.V(1).Info("registered flavor group resource", @@ -92,7 +115,11 @@ func (api *HTTPAPI) buildServiceInfo(ctx context.Context, logger logr.Logger) (l "flavorGroup", groupName, "displayName", displayName, "smallestFlavor", groupData.SmallestFlavor.Name, - "smallestRamMB", groupData.SmallestFlavor.MemoryMB) + "smallestRamMB", groupData.SmallestFlavor.MemoryMB, + "handlesCommitments", handlesCommitments, + "ramCoreRatio", groupData.RamCoreRatio, + "ramCoreRatioMin", groupData.RamCoreRatioMin, + "ramCoreRatioMax", groupData.RamCoreRatioMax) } // Get last content changed from flavor group knowledge and treat it as version diff --git a/internal/scheduling/reservations/commitments/api_info_test.go b/internal/scheduling/reservations/commitments/api_info_test.go index 71c560c19..828a255ff 100644 --- a/internal/scheduling/reservations/commitments/api_info_test.go +++ b/internal/scheduling/reservations/commitments/api_info_test.go @@ -4,11 +4,14 @@ package commitments import ( + "encoding/json" "net/http" "net/http/httptest" "testing" "github.com/cobaltcore-dev/cortex/api/v1alpha1" + "github.com/sapcc/go-api-declarations/liquid" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" "sigs.k8s.io/controller-runtime/pkg/client/fake" ) @@ -76,3 +79,120 @@ func TestHandleInfo_MethodNotAllowed(t *testing.T) { t.Errorf("expected status code %d (Method Not Allowed), got %d", http.StatusMethodNotAllowed, resp.StatusCode) } } + +func TestHandleInfo_HasCapacityEqualsHandlesCommitments(t *testing.T) { + // Test that HasCapacity == HandlesCommitments for all resources + // Both should be true only for groups with fixed RAM/core ratio + scheme := runtime.NewScheme() + if err := v1alpha1.AddToScheme(scheme); err != nil { + t.Fatalf("failed to add scheme: %v", err) + } + + // Create flavor groups knowledge with both fixed and variable ratio groups + features := []map[string]interface{}{ + { + // Group with fixed ratio - should accept commitments (HasCapacity=true, HandlesCommitments=true) + "name": "hana_fixed", + "flavors": []map[string]interface{}{ + {"name": "hana_c4_m16", "vcpus": 4, "memoryMB": 16384, "diskGB": 50}, + {"name": "hana_c8_m32", "vcpus": 8, "memoryMB": 32768, "diskGB": 100}, + }, + "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, // Fixed: 4096 MiB per vCPU for all flavors + }, + { + // Group with variable ratio - should NOT accept commitments (HasCapacity=false, HandlesCommitments=false) + "name": "v2_variable", + "flavors": []map[string]interface{}{ + {"name": "v2_c4_m8", "vcpus": 4, "memoryMB": 8192, "diskGB": 50}, // 2048 MiB/vCPU + {"name": "v2_c4_m64", "vcpus": 4, "memoryMB": 65536, "diskGB": 100}, // 16384 MiB/vCPU + }, + "largestFlavor": map[string]interface{}{"name": "v2_c4_m64", "vcpus": 4, "memoryMB": 65536, "diskGB": 100}, + "smallestFlavor": map[string]interface{}{"name": "v2_c4_m8", "vcpus": 4, "memoryMB": 8192, "diskGB": 50}, + "ramCoreRatioMin": 2048, // Variable: min ratio + "ramCoreRatioMax": 16384, // Variable: max ratio + }, + } + + 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(), + }, + } + + k8sClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(knowledge). + Build() + + api := &HTTPAPI{client: k8sClient} + + req := httptest.NewRequest(http.MethodGet, "/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 status code %d, got %d", http.StatusOK, resp.StatusCode) + } + + var serviceInfo liquid.ServiceInfo + if err := json.NewDecoder(resp.Body).Decode(&serviceInfo); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + + // Verify we have both resources + if len(serviceInfo.Resources) != 2 { + t.Fatalf("expected 2 resources, got %d", len(serviceInfo.Resources)) + } + + // Test fixed ratio group: ram_hana_fixed + fixedResource, ok := serviceInfo.Resources["ram_hana_fixed"] + if !ok { + t.Fatal("expected ram_hana_fixed resource to exist") + } + if !fixedResource.HasCapacity { + t.Error("ram_hana_fixed: expected HasCapacity=true") + } + if !fixedResource.HandlesCommitments { + t.Error("ram_hana_fixed: expected HandlesCommitments=true (fixed ratio group)") + } + if fixedResource.HasCapacity != fixedResource.HandlesCommitments { + t.Errorf("ram_hana_fixed: HasCapacity (%v) should equal HandlesCommitments (%v)", + fixedResource.HasCapacity, fixedResource.HandlesCommitments) + } + + // Test variable ratio group: ram_v2_variable + variableResource, ok := serviceInfo.Resources["ram_v2_variable"] + if !ok { + t.Fatal("expected ram_v2_variable resource to exist") + } + // Variable ratio groups don't accept commitments, and we only report capacity for groups + // that accept commitments, so both HasCapacity and HandlesCommitments should be false + if variableResource.HasCapacity { + t.Error("ram_v2_variable: expected HasCapacity=false (variable ratio groups don't report capacity)") + } + if variableResource.HandlesCommitments { + t.Error("ram_v2_variable: expected HandlesCommitments=false (variable ratio group)") + } + // Verify HasCapacity == HandlesCommitments for consistency + if variableResource.HasCapacity != variableResource.HandlesCommitments { + t.Errorf("ram_v2_variable: HasCapacity (%v) should equal HandlesCommitments (%v)", + variableResource.HasCapacity, variableResource.HandlesCommitments) + } +} diff --git a/internal/scheduling/reservations/commitments/api_report_capacity_test.go b/internal/scheduling/reservations/commitments/api_report_capacity_test.go index 76140e218..beb08c0b4 100644 --- a/internal/scheduling/reservations/commitments/api_report_capacity_test.go +++ b/internal/scheduling/reservations/commitments/api_report_capacity_test.go @@ -232,6 +232,7 @@ func createEmptyFlavorGroupKnowledge() *v1alpha1.Knowledge { } // createTestFlavorGroupKnowledge creates a test Knowledge CRD with flavor group data +// that accepts commitments (has fixed RAM/core ratio) func createTestFlavorGroupKnowledge(t *testing.T, groupName string) *v1alpha1.Knowledge { t.Helper() @@ -252,6 +253,14 @@ func createTestFlavorGroupKnowledge(t *testing.T, groupName string) *v1alpha1.Kn "memoryMB": 32768, "diskGB": 50, }, + "smallestFlavor": map[string]interface{}{ + "name": "test_c8_m32", + "vcpus": 8, + "memoryMB": 32768, + "diskGB": 50, + }, + // Fixed RAM/core ratio (4096 MiB per vCPU) - required for group to accept commitments + "ramCoreRatio": 4096, }, } diff --git a/internal/scheduling/reservations/commitments/capacity.go b/internal/scheduling/reservations/commitments/capacity.go index 04ad177e1..b61a5369e 100644 --- a/internal/scheduling/reservations/commitments/capacity.go +++ b/internal/scheduling/reservations/commitments/capacity.go @@ -8,12 +8,12 @@ import ( "fmt" "sort" - "github.com/sapcc/go-api-declarations/liquid" - "sigs.k8s.io/controller-runtime/pkg/client" - "github.com/cobaltcore-dev/cortex/api/v1alpha1" "github.com/cobaltcore-dev/cortex/internal/knowledge/extractor/plugins/compute" "github.com/cobaltcore-dev/cortex/internal/scheduling/reservations" + . "github.com/majewsky/gg/option" + "github.com/sapcc/go-api-declarations/liquid" + "sigs.k8s.io/controller-runtime/pkg/client" ) // CapacityCalculator computes capacity reports for Limes LIQUID API. @@ -25,7 +25,8 @@ func NewCapacityCalculator(client client.Client) *CapacityCalculator { return &CapacityCalculator{client: client} } -// CalculateCapacity computes per-AZ capacity for all flavor groups. +// CalculateCapacity computes per-AZ capacity for all flavor groups that accept commitments. +// Only flavor groups with a fixed RAM/core ratio are included in the report. func (c *CapacityCalculator) CalculateCapacity(ctx context.Context) (liquid.ServiceCapacityReport, error) { // Get all flavor groups from Knowledge CRDs knowledge := &reservations.FlavorGroupKnowledgeClient{Client: c.client} @@ -34,12 +35,24 @@ func (c *CapacityCalculator) CalculateCapacity(ctx context.Context) (liquid.Serv return liquid.ServiceCapacityReport{}, fmt.Errorf("failed to get flavor groups: %w", err) } - // Build capacity report per flavor group + // Get version from Knowledge CRD (same as info API version) + var infoVersion int64 = -1 + if knowledgeCRD, err := knowledge.Get(ctx); err == nil && knowledgeCRD != nil && !knowledgeCRD.Status.LastContentChange.IsZero() { + infoVersion = knowledgeCRD.Status.LastContentChange.Unix() + } + + // Build capacity report per flavor group (only for groups that accept CRs) report := liquid.ServiceCapacityReport{ - Resources: make(map[liquid.ResourceName]*liquid.ResourceCapacityReport), + InfoVersion: infoVersion, + Resources: make(map[liquid.ResourceName]*liquid.ResourceCapacityReport), } for groupName, groupData := range flavorGroups { + // Only report capacity for flavor groups that accept commitments + if !FlavorGroupAcceptsCommitments(&groupData) { + continue + } + // Resource name follows pattern: ram_ resourceName := liquid.ResourceName("ram_" + groupName) @@ -68,15 +81,14 @@ func (c *CapacityCalculator) calculateAZCapacity( return nil, fmt.Errorf("failed to get availability zones: %w", err) } - // Create report entry for each AZ with empty capacity/usage - // Capacity and Usage are left unset (zero value of option.Option[uint64]) - // This signals to Limes: "These AZs exist, but capacity/usage not yet calculated" + // Create report entry for each AZ with placeholder capacity=0 + // TODO: Calculate actual capacity from Reservation CRDs or host resources + // TODO: Calculate actual usage from VM allocations result := make(map[liquid.AvailabilityZone]*liquid.AZResourceCapacityReport) for _, az := range azs { result[liquid.AvailabilityZone(az)] = &liquid.AZResourceCapacityReport{ - // Both Capacity and Usage left unset (empty optional values) - // TODO: Calculate actual capacity from Reservation CRDs or host resources - // TODO: Calculate actual usage from VM allocations + Capacity: 0, // Placeholder: capacity=0 until actual calculation is implemented + Usage: Some[uint64](0), // Placeholder: usage=0 until actual calculation is implemented } } diff --git a/internal/scheduling/reservations/commitments/flavor_group_eligibility.go b/internal/scheduling/reservations/commitments/flavor_group_eligibility.go new file mode 100644 index 000000000..00218835f --- /dev/null +++ b/internal/scheduling/reservations/commitments/flavor_group_eligibility.go @@ -0,0 +1,31 @@ +// Copyright SAP SE +// SPDX-License-Identifier: Apache-2.0 + +package commitments + +import ( + "fmt" + + "github.com/cobaltcore-dev/cortex/internal/knowledge/extractor/plugins/compute" +) + +// FlavorGroupAcceptsCommitments returns true if the given flavor group can accept committed resources. +// Currently, only groups with a fixed RAM/core ratio (all flavors have the same ratio) accept CRs. +// This is the single source of truth for CR eligibility and should be used across all CR APIs. +func FlavorGroupAcceptsCommitments(fg *compute.FlavorGroupFeature) bool { + return fg.HasFixedRamCoreRatio() +} + +// FlavorGroupCommitmentRejectionReason returns the reason why the given flavor group does not accept CRs. +// Returns empty string if the group accepts commitments. +func FlavorGroupCommitmentRejectionReason(fg *compute.FlavorGroupFeature) string { + if FlavorGroupAcceptsCommitments(fg) { + return "" + } + // Differentiate between missing ratio metadata and variable ratio + if fg.RamCoreRatioMin == nil && fg.RamCoreRatioMax == nil { + return fmt.Sprintf("flavor group %q has no computable RAM/core ratio metadata and does not accept commitments", fg.Name) + } + return fmt.Sprintf("flavor group %q has variable RAM/core ratio (min=%d, max=%d) and does not accept commitments", + fg.Name, *fg.RamCoreRatioMin, *fg.RamCoreRatioMax) +}