From 898b19568965d1527ee5caa3a29453fe307346ce Mon Sep 17 00:00:00 2001 From: mblos Date: Fri, 27 Mar 2026 11:41:35 +0100 Subject: [PATCH 1/2] fix commitments report capacity --- .../commitments/api_report_capacity.go | 2 +- .../commitments/api_report_capacity_test.go | 53 ++++++++++++----- .../reservations/commitments/capacity.go | 59 +++---------------- 3 files changed, 47 insertions(+), 67 deletions(-) diff --git a/internal/scheduling/reservations/commitments/api_report_capacity.go b/internal/scheduling/reservations/commitments/api_report_capacity.go index 194106d92..09fc55168 100644 --- a/internal/scheduling/reservations/commitments/api_report_capacity.go +++ b/internal/scheduling/reservations/commitments/api_report_capacity.go @@ -59,7 +59,7 @@ func (api *HTTPAPI) HandleReportCapacity(w http.ResponseWriter, r *http.Request) // Calculate capacity calculator := NewCapacityCalculator(api.client) - report, err := calculator.CalculateCapacity(ctx) + report, err := calculator.CalculateCapacity(ctx, req) if err != nil { logger.Error(err, "failed to calculate capacity") statusCode = http.StatusInternalServerError diff --git a/internal/scheduling/reservations/commitments/api_report_capacity_test.go b/internal/scheduling/reservations/commitments/api_report_capacity_test.go index a0173f1f4..c121b9674 100644 --- a/internal/scheduling/reservations/commitments/api_report_capacity_test.go +++ b/internal/scheduling/reservations/commitments/api_report_capacity_test.go @@ -135,7 +135,10 @@ func TestCapacityCalculator(t *testing.T) { Build() calculator := NewCapacityCalculator(fakeClient) - _, err := calculator.CalculateCapacity(context.Background()) + req := liquid.ServiceCapacityRequest{ + AllAZs: []liquid.AvailabilityZone{"az-one", "az-two"}, + } + _, err := calculator.CalculateCapacity(context.Background(), req) if err == nil { t.Fatal("Expected error when flavor groups knowledge doesn't exist, got nil") } @@ -154,7 +157,10 @@ func TestCapacityCalculator(t *testing.T) { Build() calculator := NewCapacityCalculator(fakeClient) - report, err := calculator.CalculateCapacity(context.Background()) + req := liquid.ServiceCapacityRequest{ + AllAZs: []liquid.AvailabilityZone{"az-one", "az-two"}, + } + report, err := calculator.CalculateCapacity(context.Background(), req) if err != nil { t.Fatalf("Expected no error, got: %v", err) } @@ -168,8 +174,8 @@ func TestCapacityCalculator(t *testing.T) { } }) - t.Run("CalculateCapacity returns empty perAZ when no HostDetails exist", func(t *testing.T) { - // Create a flavor group knowledge without host details + t.Run("CalculateCapacity returns perAZ entries for all AZs from request", func(t *testing.T) { + // Create a flavor group knowledge flavorGroupKnowledge := createTestFlavorGroupKnowledge(t, "test-group") fakeClient := fake.NewClientBuilder(). @@ -178,7 +184,11 @@ func TestCapacityCalculator(t *testing.T) { Build() calculator := NewCapacityCalculator(fakeClient) - report, err := calculator.CalculateCapacity(context.Background()) + // Request specifies the AZs that must be in the report + req := liquid.ServiceCapacityRequest{ + AllAZs: []liquid.AvailabilityZone{"qa-de-1a", "qa-de-1b", "qa-de-1d"}, + } + report, err := calculator.CalculateCapacity(context.Background(), req) if err != nil { t.Fatalf("Expected no error, got: %v", err) } @@ -188,31 +198,46 @@ func TestCapacityCalculator(t *testing.T) { t.Fatalf("Expected 3 resources (_ram, _cores, _instances), got %d", len(report.Resources)) } - // Check RAM resource + // Check RAM resource has entries for all requested AZs ramResource := report.Resources[liquid.ResourceName("hw_version_test-group_ram")] if ramResource == nil { t.Fatal("Expected hw_version_test-group_ram resource to exist") } - if len(ramResource.PerAZ) != 0 { - t.Errorf("Expected 0 AZs for RAM resource, got %d", len(ramResource.PerAZ)) + if len(ramResource.PerAZ) != 3 { + t.Errorf("Expected 3 AZs for RAM resource, got %d", len(ramResource.PerAZ)) + } + for _, az := range req.AllAZs { + if _, ok := ramResource.PerAZ[az]; !ok { + t.Errorf("Expected RAM resource to have entry for AZ %s", az) + } } - // Check Cores resource + // Check Cores resource has entries for all requested AZs coresResource := report.Resources[liquid.ResourceName("hw_version_test-group_cores")] if coresResource == nil { t.Fatal("Expected hw_version_test-group_cores resource to exist") } - if len(coresResource.PerAZ) != 0 { - t.Errorf("Expected 0 AZs for Cores resource, got %d", len(coresResource.PerAZ)) + if len(coresResource.PerAZ) != 3 { + t.Errorf("Expected 3 AZs for Cores resource, got %d", len(coresResource.PerAZ)) + } + for _, az := range req.AllAZs { + if _, ok := coresResource.PerAZ[az]; !ok { + t.Errorf("Expected Cores resource to have entry for AZ %s", az) + } } - // Check Instances resource + // Check Instances resource has entries for all requested AZs instancesResource := report.Resources[liquid.ResourceName("hw_version_test-group_instances")] if instancesResource == nil { t.Fatal("Expected hw_version_test-group_instances resource to exist") } - if len(instancesResource.PerAZ) != 0 { - t.Errorf("Expected 0 AZs for Instances resource, got %d", len(instancesResource.PerAZ)) + if len(instancesResource.PerAZ) != 3 { + t.Errorf("Expected 3 AZs for Instances resource, got %d", len(instancesResource.PerAZ)) + } + for _, az := range req.AllAZs { + if _, ok := instancesResource.PerAZ[az]; !ok { + t.Errorf("Expected Instances resource to have entry for AZ %s", az) + } } }) } diff --git a/internal/scheduling/reservations/commitments/capacity.go b/internal/scheduling/reservations/commitments/capacity.go index ad4e488da..561a1d6b2 100644 --- a/internal/scheduling/reservations/commitments/capacity.go +++ b/internal/scheduling/reservations/commitments/capacity.go @@ -6,9 +6,7 @@ package commitments import ( "context" "fmt" - "sort" - "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" @@ -28,7 +26,8 @@ func NewCapacityCalculator(client client.Client) *CapacityCalculator { // CalculateCapacity computes per-AZ capacity for all flavor groups. // For each flavor group, three resources are reported: _ram, _cores, _instances. // All flavor groups are included, not just those with fixed RAM/core ratio. -func (c *CapacityCalculator) CalculateCapacity(ctx context.Context) (liquid.ServiceCapacityReport, error) { +// The request provides the list of all AZs from Limes that must be included in the report. +func (c *CapacityCalculator) CalculateCapacity(ctx context.Context, req liquid.ServiceCapacityRequest) (liquid.ServiceCapacityReport, error) { // Get all flavor groups from Knowledge CRDs knowledge := &reservations.FlavorGroupKnowledgeClient{Client: c.client} flavorGroups, err := knowledge.GetAllFlavorGroups(ctx, nil) @@ -52,7 +51,7 @@ func (c *CapacityCalculator) CalculateCapacity(ctx context.Context) (liquid.Serv // All flavor groups are included in capacity reporting (not just those with fixed ratio). // Calculate per-AZ capacity (placeholder: capacity=0 for all resources) - azCapacity, err := c.calculateAZCapacity(ctx, groupName, groupData) + azCapacity, err := c.calculateAZCapacity(ctx, groupName, groupData, req.AllAZs) if err != nil { return liquid.ServiceCapacityReport{}, fmt.Errorf("failed to calculate capacity for %s: %w", groupName, err) } @@ -101,15 +100,11 @@ func (c *CapacityCalculator) copyAZCapacity( } func (c *CapacityCalculator) calculateAZCapacity( - ctx context.Context, + _ context.Context, _ string, // groupName - reserved for future use _ compute.FlavorGroupFeature, // groupData - reserved for future use + allAZs []liquid.AvailabilityZone, // list of all AZs from Limes request ) (map[liquid.AvailabilityZone]*liquid.AZResourceCapacityReport, error) { - // Get list of availability zones from HostDetails Knowledge - azs, err := c.getAvailabilityZones(ctx) - if err != nil { - return nil, fmt.Errorf("failed to get availability zones: %w", err) - } // Create report entry for each AZ with placeholder capacity=0. // @@ -123,8 +118,8 @@ func (c *CapacityCalculator) calculateAZCapacity( // 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{ + for _, az := range allAZs { + result[az] = &liquid.AZResourceCapacityReport{ Capacity: 0, // Placeholder: capacity=0 until actual calculation is implemented Usage: Some[uint64](0), // Placeholder: usage=0 until actual calculation is implemented } @@ -132,43 +127,3 @@ func (c *CapacityCalculator) calculateAZCapacity( return result, nil } - -func (c *CapacityCalculator) getAvailabilityZones(ctx context.Context) ([]string, error) { - // List all Knowledge CRDs to find host-details knowledge - var knowledgeList v1alpha1.KnowledgeList - if err := c.client.List(ctx, &knowledgeList); err != nil { - return nil, fmt.Errorf("failed to list Knowledge CRDs: %w", err) - } - - // Find host-details knowledge and extract AZs - azSet := make(map[string]struct{}) - for _, knowledge := range knowledgeList.Items { - // Look for host-details extractor - if knowledge.Spec.Extractor.Name != "host_details" { - continue - } - - // Parse features from Raw data - features, err := v1alpha1.UnboxFeatureList[compute.HostDetails](knowledge.Status.Raw) - if err != nil { - // Skip if we can't parse this knowledge - continue - } - - // Collect unique AZ names - for _, feature := range features { - if feature.AvailabilityZone != "" { - azSet[feature.AvailabilityZone] = struct{}{} - } - } - } - - // Convert set to sorted slice - azs := make([]string, 0, len(azSet)) - for az := range azSet { - azs = append(azs, az) - } - sort.Strings(azs) - - return azs, nil -} From 36797e295846c63c37351856509a066952442b77 Mon Sep 17 00:00:00 2001 From: mblos Date: Fri, 27 Mar 2026 11:48:52 +0100 Subject: [PATCH 2/2] fix response --- .../commitments/api_report_capacity_test.go | 111 ++++++++++++------ .../reservations/commitments/capacity.go | 10 +- 2 files changed, 80 insertions(+), 41 deletions(-) diff --git a/internal/scheduling/reservations/commitments/api_report_capacity_test.go b/internal/scheduling/reservations/commitments/api_report_capacity_test.go index c121b9674..b151382cf 100644 --- a/internal/scheduling/reservations/commitments/api_report_capacity_test.go +++ b/internal/scheduling/reservations/commitments/api_report_capacity_test.go @@ -9,6 +9,7 @@ import ( "encoding/json" "net/http" "net/http/httptest" + "slices" "strings" "testing" @@ -175,16 +176,13 @@ func TestCapacityCalculator(t *testing.T) { }) t.Run("CalculateCapacity returns perAZ entries for all AZs from request", func(t *testing.T) { - // Create a flavor group knowledge flavorGroupKnowledge := createTestFlavorGroupKnowledge(t, "test-group") - fakeClient := fake.NewClientBuilder(). WithScheme(scheme). WithObjects(flavorGroupKnowledge). Build() calculator := NewCapacityCalculator(fakeClient) - // Request specifies the AZs that must be in the report req := liquid.ServiceCapacityRequest{ AllAZs: []liquid.AvailabilityZone{"qa-de-1a", "qa-de-1b", "qa-de-1d"}, } @@ -193,55 +191,100 @@ func TestCapacityCalculator(t *testing.T) { t.Fatalf("Expected no error, got: %v", err) } - // Now we have 3 resources per flavor group: _ram, _cores, _instances if len(report.Resources) != 3 { t.Fatalf("Expected 3 resources (_ram, _cores, _instances), got %d", len(report.Resources)) } - // Check RAM resource has entries for all requested AZs - ramResource := report.Resources[liquid.ResourceName("hw_version_test-group_ram")] - if ramResource == nil { - t.Fatal("Expected hw_version_test-group_ram resource to exist") + // Verify all resources have exactly the requested AZs + verifyPerAZMatchesRequest(t, report.Resources["hw_version_test-group_ram"], req.AllAZs) + verifyPerAZMatchesRequest(t, report.Resources["hw_version_test-group_cores"], req.AllAZs) + verifyPerAZMatchesRequest(t, report.Resources["hw_version_test-group_instances"], req.AllAZs) + }) + + t.Run("CalculateCapacity with empty AllAZs returns empty perAZ maps", func(t *testing.T) { + flavorGroupKnowledge := createTestFlavorGroupKnowledge(t, "test-group") + fakeClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(flavorGroupKnowledge). + Build() + + calculator := NewCapacityCalculator(fakeClient) + req := liquid.ServiceCapacityRequest{AllAZs: []liquid.AvailabilityZone{}} + report, err := calculator.CalculateCapacity(context.Background(), req) + if err != nil { + t.Fatalf("Expected no error, got: %v", err) } - if len(ramResource.PerAZ) != 3 { - t.Errorf("Expected 3 AZs for RAM resource, got %d", len(ramResource.PerAZ)) + + if len(report.Resources) != 3 { + t.Fatalf("Expected 3 resources, got %d", len(report.Resources)) } - for _, az := range req.AllAZs { - if _, ok := ramResource.PerAZ[az]; !ok { - t.Errorf("Expected RAM resource to have entry for AZ %s", az) + + for resName, res := range report.Resources { + if len(res.PerAZ) != 0 { + t.Errorf("%s: expected empty PerAZ, got %d entries", resName, len(res.PerAZ)) } } + }) - // Check Cores resource has entries for all requested AZs - coresResource := report.Resources[liquid.ResourceName("hw_version_test-group_cores")] - if coresResource == nil { - t.Fatal("Expected hw_version_test-group_cores resource to exist") - } - if len(coresResource.PerAZ) != 3 { - t.Errorf("Expected 3 AZs for Cores resource, got %d", len(coresResource.PerAZ)) + t.Run("CalculateCapacity responds to different AZ sets correctly", func(t *testing.T) { + flavorGroupKnowledge := createTestFlavorGroupKnowledge(t, "test-group") + fakeClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(flavorGroupKnowledge). + Build() + + calculator := NewCapacityCalculator(fakeClient) + + req1 := liquid.ServiceCapacityRequest{ + AllAZs: []liquid.AvailabilityZone{"eu-de-1a", "eu-de-1b"}, } - for _, az := range req.AllAZs { - if _, ok := coresResource.PerAZ[az]; !ok { - t.Errorf("Expected Cores resource to have entry for AZ %s", az) - } + report1, err := calculator.CalculateCapacity(context.Background(), req1) + if err != nil { + t.Fatalf("Expected no error, got: %v", err) } - // Check Instances resource has entries for all requested AZs - instancesResource := report.Resources[liquid.ResourceName("hw_version_test-group_instances")] - if instancesResource == nil { - t.Fatal("Expected hw_version_test-group_instances resource to exist") + req2 := liquid.ServiceCapacityRequest{ + AllAZs: []liquid.AvailabilityZone{"us-west-1a", "us-west-1b", "us-west-1c", "us-west-1d"}, } - if len(instancesResource.PerAZ) != 3 { - t.Errorf("Expected 3 AZs for Instances resource, got %d", len(instancesResource.PerAZ)) + report2, err := calculator.CalculateCapacity(context.Background(), req2) + if err != nil { + t.Fatalf("Expected no error, got: %v", err) } - for _, az := range req.AllAZs { - if _, ok := instancesResource.PerAZ[az]; !ok { - t.Errorf("Expected Instances resource to have entry for AZ %s", az) - } + + // Verify reports have exactly the requested AZs + for _, res := range report1.Resources { + verifyPerAZMatchesRequest(t, res, req1.AllAZs) + } + for _, res := range report2.Resources { + verifyPerAZMatchesRequest(t, res, req2.AllAZs) } }) } +// 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) { + t.Helper() + if res == nil { + t.Error("resource is nil") + return + } + if len(res.PerAZ) != len(requestedAZs) { + t.Errorf("expected %d AZs, got %d", len(requestedAZs), len(res.PerAZ)) + } + for _, az := range requestedAZs { + if _, ok := res.PerAZ[az]; !ok { + t.Errorf("missing entry for requested AZ %s", az) + } + } + for az := range res.PerAZ { + if !slices.Contains(requestedAZs, az) { + t.Errorf("unexpected AZ %s in response (not in request)", az) + } + } +} + // createEmptyFlavorGroupKnowledge creates an empty flavor groups Knowledge CRD func createEmptyFlavorGroupKnowledge() *v1alpha1.Knowledge { // Box empty array properly diff --git a/internal/scheduling/reservations/commitments/capacity.go b/internal/scheduling/reservations/commitments/capacity.go index 561a1d6b2..8cd3a7159 100644 --- a/internal/scheduling/reservations/commitments/capacity.go +++ b/internal/scheduling/reservations/commitments/capacity.go @@ -51,10 +51,7 @@ func (c *CapacityCalculator) CalculateCapacity(ctx context.Context, req liquid.S // All flavor groups are included in capacity reporting (not just those with fixed ratio). // Calculate per-AZ capacity (placeholder: capacity=0 for all resources) - azCapacity, err := c.calculateAZCapacity(ctx, groupName, groupData, req.AllAZs) - if err != nil { - return liquid.ServiceCapacityReport{}, fmt.Errorf("failed to calculate capacity for %s: %w", groupName, err) - } + azCapacity := c.calculateAZCapacity(groupName, groupData, req.AllAZs) // === 1. RAM Resource === ramResourceName := liquid.ResourceName(ResourceNameRAM(groupName)) @@ -100,11 +97,10 @@ func (c *CapacityCalculator) copyAZCapacity( } func (c *CapacityCalculator) calculateAZCapacity( - _ context.Context, _ string, // groupName - reserved for future use _ compute.FlavorGroupFeature, // groupData - reserved for future use allAZs []liquid.AvailabilityZone, // list of all AZs from Limes request -) (map[liquid.AvailabilityZone]*liquid.AZResourceCapacityReport, error) { +) map[liquid.AvailabilityZone]*liquid.AZResourceCapacityReport { // Create report entry for each AZ with placeholder capacity=0. // @@ -125,5 +121,5 @@ func (c *CapacityCalculator) calculateAZCapacity( } } - return result, nil + return result }