Skip to content

Commit d493f0c

Browse files
authored
fix commitments report capacity (#658)
1 parent d59b8ff commit d493f0c

3 files changed

Lines changed: 102 additions & 83 deletions

File tree

internal/scheduling/reservations/commitments/api_report_capacity.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ func (api *HTTPAPI) HandleReportCapacity(w http.ResponseWriter, r *http.Request)
5959

6060
// Calculate capacity
6161
calculator := NewCapacityCalculator(api.client)
62-
report, err := calculator.CalculateCapacity(ctx)
62+
report, err := calculator.CalculateCapacity(ctx, req)
6363
if err != nil {
6464
logger.Error(err, "failed to calculate capacity")
6565
statusCode = http.StatusInternalServerError

internal/scheduling/reservations/commitments/api_report_capacity_test.go

Lines changed: 93 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99
"encoding/json"
1010
"net/http"
1111
"net/http/httptest"
12+
"slices"
1213
"strings"
1314
"testing"
1415

@@ -135,7 +136,10 @@ func TestCapacityCalculator(t *testing.T) {
135136
Build()
136137

137138
calculator := NewCapacityCalculator(fakeClient)
138-
_, err := calculator.CalculateCapacity(context.Background())
139+
req := liquid.ServiceCapacityRequest{
140+
AllAZs: []liquid.AvailabilityZone{"az-one", "az-two"},
141+
}
142+
_, err := calculator.CalculateCapacity(context.Background(), req)
139143
if err == nil {
140144
t.Fatal("Expected error when flavor groups knowledge doesn't exist, got nil")
141145
}
@@ -154,7 +158,10 @@ func TestCapacityCalculator(t *testing.T) {
154158
Build()
155159

156160
calculator := NewCapacityCalculator(fakeClient)
157-
report, err := calculator.CalculateCapacity(context.Background())
161+
req := liquid.ServiceCapacityRequest{
162+
AllAZs: []liquid.AvailabilityZone{"az-one", "az-two"},
163+
}
164+
report, err := calculator.CalculateCapacity(context.Background(), req)
158165
if err != nil {
159166
t.Fatalf("Expected no error, got: %v", err)
160167
}
@@ -168,55 +175,116 @@ func TestCapacityCalculator(t *testing.T) {
168175
}
169176
})
170177

171-
t.Run("CalculateCapacity returns empty perAZ when no HostDetails exist", func(t *testing.T) {
172-
// Create a flavor group knowledge without host details
178+
t.Run("CalculateCapacity returns perAZ entries for all AZs from request", func(t *testing.T) {
173179
flavorGroupKnowledge := createTestFlavorGroupKnowledge(t, "test-group")
174-
175180
fakeClient := fake.NewClientBuilder().
176181
WithScheme(scheme).
177182
WithObjects(flavorGroupKnowledge).
178183
Build()
179184

180185
calculator := NewCapacityCalculator(fakeClient)
181-
report, err := calculator.CalculateCapacity(context.Background())
186+
req := liquid.ServiceCapacityRequest{
187+
AllAZs: []liquid.AvailabilityZone{"qa-de-1a", "qa-de-1b", "qa-de-1d"},
188+
}
189+
report, err := calculator.CalculateCapacity(context.Background(), req)
182190
if err != nil {
183191
t.Fatalf("Expected no error, got: %v", err)
184192
}
185193

186-
// Now we have 3 resources per flavor group: _ram, _cores, _instances
187194
if len(report.Resources) != 3 {
188195
t.Fatalf("Expected 3 resources (_ram, _cores, _instances), got %d", len(report.Resources))
189196
}
190197

191-
// Check RAM resource
192-
ramResource := report.Resources[liquid.ResourceName("hw_version_test-group_ram")]
193-
if ramResource == nil {
194-
t.Fatal("Expected hw_version_test-group_ram resource to exist")
198+
// Verify all resources have exactly the requested AZs
199+
verifyPerAZMatchesRequest(t, report.Resources["hw_version_test-group_ram"], req.AllAZs)
200+
verifyPerAZMatchesRequest(t, report.Resources["hw_version_test-group_cores"], req.AllAZs)
201+
verifyPerAZMatchesRequest(t, report.Resources["hw_version_test-group_instances"], req.AllAZs)
202+
})
203+
204+
t.Run("CalculateCapacity with empty AllAZs returns empty perAZ maps", func(t *testing.T) {
205+
flavorGroupKnowledge := createTestFlavorGroupKnowledge(t, "test-group")
206+
fakeClient := fake.NewClientBuilder().
207+
WithScheme(scheme).
208+
WithObjects(flavorGroupKnowledge).
209+
Build()
210+
211+
calculator := NewCapacityCalculator(fakeClient)
212+
req := liquid.ServiceCapacityRequest{AllAZs: []liquid.AvailabilityZone{}}
213+
report, err := calculator.CalculateCapacity(context.Background(), req)
214+
if err != nil {
215+
t.Fatalf("Expected no error, got: %v", err)
195216
}
196-
if len(ramResource.PerAZ) != 0 {
197-
t.Errorf("Expected 0 AZs for RAM resource, got %d", len(ramResource.PerAZ))
217+
218+
if len(report.Resources) != 3 {
219+
t.Fatalf("Expected 3 resources, got %d", len(report.Resources))
198220
}
199221

200-
// Check Cores resource
201-
coresResource := report.Resources[liquid.ResourceName("hw_version_test-group_cores")]
202-
if coresResource == nil {
203-
t.Fatal("Expected hw_version_test-group_cores resource to exist")
222+
for resName, res := range report.Resources {
223+
if len(res.PerAZ) != 0 {
224+
t.Errorf("%s: expected empty PerAZ, got %d entries", resName, len(res.PerAZ))
225+
}
204226
}
205-
if len(coresResource.PerAZ) != 0 {
206-
t.Errorf("Expected 0 AZs for Cores resource, got %d", len(coresResource.PerAZ))
227+
})
228+
229+
t.Run("CalculateCapacity responds to different AZ sets correctly", func(t *testing.T) {
230+
flavorGroupKnowledge := createTestFlavorGroupKnowledge(t, "test-group")
231+
fakeClient := fake.NewClientBuilder().
232+
WithScheme(scheme).
233+
WithObjects(flavorGroupKnowledge).
234+
Build()
235+
236+
calculator := NewCapacityCalculator(fakeClient)
237+
238+
req1 := liquid.ServiceCapacityRequest{
239+
AllAZs: []liquid.AvailabilityZone{"eu-de-1a", "eu-de-1b"},
240+
}
241+
report1, err := calculator.CalculateCapacity(context.Background(), req1)
242+
if err != nil {
243+
t.Fatalf("Expected no error, got: %v", err)
244+
}
245+
246+
req2 := liquid.ServiceCapacityRequest{
247+
AllAZs: []liquid.AvailabilityZone{"us-west-1a", "us-west-1b", "us-west-1c", "us-west-1d"},
248+
}
249+
report2, err := calculator.CalculateCapacity(context.Background(), req2)
250+
if err != nil {
251+
t.Fatalf("Expected no error, got: %v", err)
207252
}
208253

209-
// Check Instances resource
210-
instancesResource := report.Resources[liquid.ResourceName("hw_version_test-group_instances")]
211-
if instancesResource == nil {
212-
t.Fatal("Expected hw_version_test-group_instances resource to exist")
254+
// Verify reports have exactly the requested AZs
255+
for _, res := range report1.Resources {
256+
verifyPerAZMatchesRequest(t, res, req1.AllAZs)
213257
}
214-
if len(instancesResource.PerAZ) != 0 {
215-
t.Errorf("Expected 0 AZs for Instances resource, got %d", len(instancesResource.PerAZ))
258+
for _, res := range report2.Resources {
259+
verifyPerAZMatchesRequest(t, res, req2.AllAZs)
216260
}
217261
})
218262
}
219263

264+
// verifyPerAZMatchesRequest checks that perAZ entries match exactly the requested AZs.
265+
// This follows the same semantics as nova liquid: the response must contain
266+
// entries for all AZs in AllAZs, no more and no less.
267+
func verifyPerAZMatchesRequest(t *testing.T, res *liquid.ResourceCapacityReport, requestedAZs []liquid.AvailabilityZone) {
268+
t.Helper()
269+
if res == nil {
270+
t.Error("resource is nil")
271+
return
272+
}
273+
if len(res.PerAZ) != len(requestedAZs) {
274+
t.Errorf("expected %d AZs, got %d", len(requestedAZs), len(res.PerAZ))
275+
}
276+
for _, az := range requestedAZs {
277+
if _, ok := res.PerAZ[az]; !ok {
278+
t.Errorf("missing entry for requested AZ %s", az)
279+
}
280+
}
281+
for az := range res.PerAZ {
282+
if !slices.Contains(requestedAZs, az) {
283+
t.Errorf("unexpected AZ %s in response (not in request)", az)
284+
}
285+
}
286+
}
287+
220288
// createEmptyFlavorGroupKnowledge creates an empty flavor groups Knowledge CRD
221289
func createEmptyFlavorGroupKnowledge() *v1alpha1.Knowledge {
222290
// Box empty array properly

internal/scheduling/reservations/commitments/capacity.go

Lines changed: 8 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,7 @@ package commitments
66
import (
77
"context"
88
"fmt"
9-
"sort"
109

11-
"github.com/cobaltcore-dev/cortex/api/v1alpha1"
1210
"github.com/cobaltcore-dev/cortex/internal/knowledge/extractor/plugins/compute"
1311
"github.com/cobaltcore-dev/cortex/internal/scheduling/reservations"
1412
. "github.com/majewsky/gg/option"
@@ -28,7 +26,8 @@ func NewCapacityCalculator(client client.Client) *CapacityCalculator {
2826
// CalculateCapacity computes per-AZ capacity for all flavor groups.
2927
// For each flavor group, three resources are reported: _ram, _cores, _instances.
3028
// All flavor groups are included, not just those with fixed RAM/core ratio.
31-
func (c *CapacityCalculator) CalculateCapacity(ctx context.Context) (liquid.ServiceCapacityReport, error) {
29+
// The request provides the list of all AZs from Limes that must be included in the report.
30+
func (c *CapacityCalculator) CalculateCapacity(ctx context.Context, req liquid.ServiceCapacityRequest) (liquid.ServiceCapacityReport, error) {
3231
// Get all flavor groups from Knowledge CRDs
3332
knowledge := &reservations.FlavorGroupKnowledgeClient{Client: c.client}
3433
flavorGroups, err := knowledge.GetAllFlavorGroups(ctx, nil)
@@ -52,10 +51,7 @@ func (c *CapacityCalculator) CalculateCapacity(ctx context.Context) (liquid.Serv
5251
// All flavor groups are included in capacity reporting (not just those with fixed ratio).
5352

5453
// Calculate per-AZ capacity (placeholder: capacity=0 for all resources)
55-
azCapacity, err := c.calculateAZCapacity(ctx, groupName, groupData)
56-
if err != nil {
57-
return liquid.ServiceCapacityReport{}, fmt.Errorf("failed to calculate capacity for %s: %w", groupName, err)
58-
}
54+
azCapacity := c.calculateAZCapacity(groupName, groupData, req.AllAZs)
5955

6056
// === 1. RAM Resource ===
6157
ramResourceName := liquid.ResourceName(ResourceNameRAM(groupName))
@@ -101,15 +97,10 @@ func (c *CapacityCalculator) copyAZCapacity(
10197
}
10298

10399
func (c *CapacityCalculator) calculateAZCapacity(
104-
ctx context.Context,
105100
_ string, // groupName - reserved for future use
106101
_ compute.FlavorGroupFeature, // groupData - reserved for future use
107-
) (map[liquid.AvailabilityZone]*liquid.AZResourceCapacityReport, error) {
108-
// Get list of availability zones from HostDetails Knowledge
109-
azs, err := c.getAvailabilityZones(ctx)
110-
if err != nil {
111-
return nil, fmt.Errorf("failed to get availability zones: %w", err)
112-
}
102+
allAZs []liquid.AvailabilityZone, // list of all AZs from Limes request
103+
) map[liquid.AvailabilityZone]*liquid.AZResourceCapacityReport {
113104

114105
// Create report entry for each AZ with placeholder capacity=0.
115106
//
@@ -123,52 +114,12 @@ func (c *CapacityCalculator) calculateAZCapacity(
123114
// TODO: Calculate actual capacity from Reservation CRDs or host resources
124115
// TODO: Calculate actual usage from VM allocations
125116
result := make(map[liquid.AvailabilityZone]*liquid.AZResourceCapacityReport)
126-
for _, az := range azs {
127-
result[liquid.AvailabilityZone(az)] = &liquid.AZResourceCapacityReport{
117+
for _, az := range allAZs {
118+
result[az] = &liquid.AZResourceCapacityReport{
128119
Capacity: 0, // Placeholder: capacity=0 until actual calculation is implemented
129120
Usage: Some[uint64](0), // Placeholder: usage=0 until actual calculation is implemented
130121
}
131122
}
132123

133-
return result, nil
134-
}
135-
136-
func (c *CapacityCalculator) getAvailabilityZones(ctx context.Context) ([]string, error) {
137-
// List all Knowledge CRDs to find host-details knowledge
138-
var knowledgeList v1alpha1.KnowledgeList
139-
if err := c.client.List(ctx, &knowledgeList); err != nil {
140-
return nil, fmt.Errorf("failed to list Knowledge CRDs: %w", err)
141-
}
142-
143-
// Find host-details knowledge and extract AZs
144-
azSet := make(map[string]struct{})
145-
for _, knowledge := range knowledgeList.Items {
146-
// Look for host-details extractor
147-
if knowledge.Spec.Extractor.Name != "host_details" {
148-
continue
149-
}
150-
151-
// Parse features from Raw data
152-
features, err := v1alpha1.UnboxFeatureList[compute.HostDetails](knowledge.Status.Raw)
153-
if err != nil {
154-
// Skip if we can't parse this knowledge
155-
continue
156-
}
157-
158-
// Collect unique AZ names
159-
for _, feature := range features {
160-
if feature.AvailabilityZone != "" {
161-
azSet[feature.AvailabilityZone] = struct{}{}
162-
}
163-
}
164-
}
165-
166-
// Convert set to sorted slice
167-
azs := make([]string, 0, len(azSet))
168-
for az := range azSet {
169-
azs = append(azs, az)
170-
}
171-
sort.Strings(azs)
172-
173-
return azs, nil
124+
return result
174125
}

0 commit comments

Comments
 (0)