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..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" @@ -135,7 +136,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 +158,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,55 +175,116 @@ 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) { flavorGroupKnowledge := createTestFlavorGroupKnowledge(t, "test-group") - fakeClient := fake.NewClientBuilder(). WithScheme(scheme). WithObjects(flavorGroupKnowledge). Build() calculator := NewCapacityCalculator(fakeClient) - report, err := calculator.CalculateCapacity(context.Background()) + 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) } - // 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 - 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) != 0 { - t.Errorf("Expected 0 AZs for RAM resource, got %d", len(ramResource.PerAZ)) + + if len(report.Resources) != 3 { + t.Fatalf("Expected 3 resources, got %d", len(report.Resources)) } - // Check Cores resource - coresResource := report.Resources[liquid.ResourceName("hw_version_test-group_cores")] - if coresResource == nil { - t.Fatal("Expected hw_version_test-group_cores resource to exist") + for resName, res := range report.Resources { + if len(res.PerAZ) != 0 { + t.Errorf("%s: expected empty PerAZ, got %d entries", resName, len(res.PerAZ)) + } } - if len(coresResource.PerAZ) != 0 { - t.Errorf("Expected 0 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"}, + } + report1, err := calculator.CalculateCapacity(context.Background(), req1) + if err != nil { + t.Fatalf("Expected no error, got: %v", err) + } + + req2 := liquid.ServiceCapacityRequest{ + AllAZs: []liquid.AvailabilityZone{"us-west-1a", "us-west-1b", "us-west-1c", "us-west-1d"}, + } + report2, err := calculator.CalculateCapacity(context.Background(), req2) + if err != nil { + t.Fatalf("Expected no error, got: %v", err) } - // Check Instances resource - instancesResource := report.Resources[liquid.ResourceName("hw_version_test-group_instances")] - if instancesResource == nil { - t.Fatal("Expected hw_version_test-group_instances resource to exist") + // Verify reports have exactly the requested AZs + for _, res := range report1.Resources { + verifyPerAZMatchesRequest(t, res, req1.AllAZs) } - if len(instancesResource.PerAZ) != 0 { - t.Errorf("Expected 0 AZs for Instances resource, got %d", len(instancesResource.PerAZ)) + 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 ad4e488da..8cd3a7159 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,10 +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) - 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)) @@ -101,15 +97,10 @@ func (c *CapacityCalculator) copyAZCapacity( } func (c *CapacityCalculator) calculateAZCapacity( - ctx context.Context, _ string, // groupName - reserved for future use _ compute.FlavorGroupFeature, // groupData - reserved for future use -) (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) - } + allAZs []liquid.AvailabilityZone, // list of all AZs from Limes request +) map[liquid.AvailabilityZone]*liquid.AZResourceCapacityReport { // Create report entry for each AZ with placeholder capacity=0. // @@ -123,52 +114,12 @@ 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 } } - 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 + return result }