Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"encoding/json"
"net/http"
"net/http/httptest"
"slices"
"strings"
"testing"

Expand Down Expand Up @@ -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")
}
Expand All @@ -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)
}
Expand All @@ -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
Expand Down
65 changes: 8 additions & 57 deletions internal/scheduling/reservations/commitments/capacity.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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)
Expand All @@ -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))
Expand Down Expand Up @@ -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.
//
Expand All @@ -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
}
Loading