Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
88 changes: 62 additions & 26 deletions internal/scheduling/reservations/commitments/api_info.go
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,10 @@ type resourceAttributes struct {
}

// buildServiceInfo constructs the ServiceInfo response with metadata for all flavor groups.
// For each flavor group that accepts commitments, three resources are registered:
// - _ram: RAM resource (unit = multiples of smallest flavor RAM, HandlesCommitments=true)
// - _cores: CPU cores resource (unit = 1, HandlesCommitments=false)
// - _instances: Instance count resource (unit = 1, HandlesCommitments=false)
func (api *HTTPAPI) buildServiceInfo(ctx context.Context, logger logr.Logger) (liquid.ServiceInfo, error) {
// Get all flavor groups from Knowledge CRDs
knowledge := &reservations.FlavorGroupKnowledgeClient{Client: api.client}
Expand All @@ -107,67 +111,99 @@ func (api *HTTPAPI) buildServiceInfo(ctx context.Context, logger logr.Logger) (l
// Build resources map
resources := make(map[liquid.ResourceName]liquid.ResourceInfo)
for groupName, groupData := range flavorGroups {
resourceName := liquid.ResourceName(ResourceNameFromFlavorGroup(groupName))
// Only handle commitments for groups with a fixed RAM/core ratio
handlesCommitments := FlavorGroupAcceptsCommitments(&groupData)
if !handlesCommitments {
continue // Skip groups that don't accept commitments
}

flavorNames := make([]string, 0, len(groupData.Flavors))
for _, flavor := range groupData.Flavors {
flavorNames = append(flavorNames, flavor.Name)
}
displayName := fmt.Sprintf(
"multiples of %d MiB (usable by: %s)",
groupData.SmallestFlavor.MemoryMB,
strings.Join(flavorNames, ", "),
)

// Only handle commitments for groups with a fixed RAM/core ratio
handlesCommitments := FlavorGroupAcceptsCommitments(&groupData)
flavorListStr := strings.Join(flavorNames, ", ")

// Build attributes JSON with ratio info
// Build attributes JSON with ratio info (shared across all resource types)
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)
logger.Error(err, "failed to marshal resource attributes", "flavorGroup", groupName)
attrsJSON = nil
}

// Build unit from smallest flavor memory (e.g., "131072 MiB" for 128 GiB)
// Validate memory is positive to avoid panic in MultiplyBy (which panics on factor=0)
if groupData.SmallestFlavor.MemoryMB == 0 {
return liquid.ServiceInfo{}, fmt.Errorf("%w: flavor group %q has invalid smallest flavor with memoryMB=0",
errInternalServiceInfo, groupName)
}
unit, err := liquid.UnitMebibytes.MultiplyBy(groupData.SmallestFlavor.MemoryMB)

// === 1. RAM Resource ===
ramResourceName := liquid.ResourceName(ResourceNameRAM(groupName))
ramUnit, err := liquid.UnitMebibytes.MultiplyBy(groupData.SmallestFlavor.MemoryMB)
if err != nil {
// Note: This error only occurs on uint64 overflow, which is unrealistic for memory values
return liquid.ServiceInfo{}, fmt.Errorf("%w: failed to create unit for flavor group %q: %w",
errInternalServiceInfo, groupName, err)
}

resources[resourceName] = liquid.ResourceInfo{
DisplayName: displayName,
Unit: unit, // Non-standard unit: multiples of smallest flavor RAM
resources[ramResourceName] = liquid.ResourceInfo{
DisplayName: fmt.Sprintf(
"multiples of %d MiB (usable by: %s)",
groupData.SmallestFlavor.MemoryMB,
flavorListStr,
),
Unit: ramUnit, // Non-standard unit: multiples of smallest flavor RAM
Topology: liquid.AZAwareTopology, // Commitments are per-AZ
NeedsResourceDemand: false, // Capacity planning out of scope for now
HasCapacity: handlesCommitments, // We report capacity via /commitments/v1/report-capacity only for groups that accept commitments
HasCapacity: true, // We report capacity via /commitments/v1/report-capacity
HasQuota: false, // No quota enforcement as of now
HandlesCommitments: handlesCommitments, // Only for groups with fixed RAM/core ratio
HandlesCommitments: true, // RAM is the primary commitment resource
Attributes: attrsJSON,
}

logger.V(1).Info("registered flavor group resource",
"resourceName", resourceName,
// === 2. Cores Resource ===
coresResourceName := liquid.ResourceName(ResourceNameCores(groupName))
resources[coresResourceName] = liquid.ResourceInfo{
DisplayName: fmt.Sprintf(
"CPU cores (usable by: %s)",
flavorListStr,
),
Unit: liquid.UnitNone, // Unit = 1 (count of cores)
Topology: liquid.AZAwareTopology, // Same topology as RAM
NeedsResourceDemand: false,
HasCapacity: true, // We report capacity (as 0 for now)
HasQuota: false, // No quota enforcement
HandlesCommitments: false, // Cores are derived from RAM commitments
Attributes: attrsJSON, // Same attributes (ratio info)
}

// === 3. Instances Resource ===
instancesResourceName := liquid.ResourceName(ResourceNameInstances(groupName))
resources[instancesResourceName] = liquid.ResourceInfo{
DisplayName: fmt.Sprintf(
"instances (usable by: %s)",
flavorListStr,
),
Unit: liquid.UnitNone, // Unit = 1 (count of instances)
Topology: liquid.AZAwareTopology, // Same topology as RAM
NeedsResourceDemand: false,
HasCapacity: true, // We report capacity (as 0 for now)
HasQuota: false, // No quota enforcement
HandlesCommitments: false, // Instances are derived from RAM commitments
Attributes: attrsJSON, // Same attributes
}

logger.V(1).Info("registered flavor group resources",
"flavorGroup", groupName,
"displayName", displayName,
"ramResource", ramResourceName,
"coresResource", coresResourceName,
"instancesResource", instancesResourceName,
"smallestFlavor", groupData.SmallestFlavor.Name,
"smallestRamMB", groupData.SmallestFlavor.MemoryMB,
"handlesCommitments", handlesCommitments,
"ramCoreRatio", groupData.RamCoreRatio,
"ramCoreRatioMin", groupData.RamCoreRatioMin,
"ramCoreRatioMax", groupData.RamCoreRatioMax)
"ramCoreRatio", groupData.RamCoreRatio)
}

// Get last content changed from flavor group knowledge and treat it as version
Expand Down
74 changes: 46 additions & 28 deletions internal/scheduling/reservations/commitments/api_info_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -138,8 +138,11 @@ func TestHandleInfo_InvalidFlavorMemory(t *testing.T) {
}

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
// Test that for flavor groups that accept commitments:
// - Three resources are created: _ram, _cores, _instances
// - Only _ram has HandlesCommitments=true
// - All three have HasCapacity=true
// Groups that DON'T accept commitments are skipped entirely
scheme := runtime.NewScheme()
if err := v1alpha1.AddToScheme(scheme); err != nil {
t.Fatalf("failed to add scheme: %v", err)
Expand All @@ -148,7 +151,8 @@ func TestHandleInfo_HasCapacityEqualsHandlesCommitments(t *testing.T) {
// 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)
// Group with fixed ratio - should accept commitments
// Creates 3 resources: _ram, _cores, _instances
"name": "hana_fixed",
"flavors": []map[string]interface{}{
{"name": "hana_c4_m16", "vcpus": 4, "memoryMB": 16384, "diskGB": 50},
Expand All @@ -159,7 +163,8 @@ func TestHandleInfo_HasCapacityEqualsHandlesCommitments(t *testing.T) {
"ramCoreRatio": 4096, // Fixed: 4096 MiB per vCPU for all flavors
},
{
// Group with variable ratio - should NOT accept commitments (HasCapacity=false, HandlesCommitments=false)
// Group with variable ratio - should NOT accept commitments
// Will be SKIPPED entirely (no resources created)
"name": "v2_variable",
"flavors": []map[string]interface{}{
{"name": "v2_c4_m8", "vcpus": 4, "memoryMB": 8192, "diskGB": 50}, // 2048 MiB/vCPU
Expand Down Expand Up @@ -213,43 +218,56 @@ func TestHandleInfo_HasCapacityEqualsHandlesCommitments(t *testing.T) {
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))
// Verify we have 3 resources for the fixed ratio group (variable ratio is skipped)
// hana_fixed generates: _ram, _cores, _instances
if len(serviceInfo.Resources) != 3 {
t.Fatalf("expected 3 resources (_ram, _cores, _instances for hana_fixed), got %d", len(serviceInfo.Resources))
}

// Test fixed ratio group: hw_version_hana_fixed_ram
fixedResource, ok := serviceInfo.Resources["hw_version_hana_fixed_ram"]
// Test RAM resource: hw_version_hana_fixed_ram
ramResource, ok := serviceInfo.Resources["hw_version_hana_fixed_ram"]
if !ok {
t.Fatal("expected hw_version_hana_fixed_ram resource to exist")
}
if !fixedResource.HasCapacity {
if !ramResource.HasCapacity {
t.Error("hw_version_hana_fixed_ram: expected HasCapacity=true")
}
if !fixedResource.HandlesCommitments {
t.Error("hw_version_hana_fixed_ram: expected HandlesCommitments=true (fixed ratio group)")
if !ramResource.HandlesCommitments {
t.Error("hw_version_hana_fixed_ram: expected HandlesCommitments=true (RAM is primary commitment resource)")
}
if fixedResource.HasCapacity != fixedResource.HandlesCommitments {
t.Errorf("hw_version_hana_fixed_ram: HasCapacity (%v) should equal HandlesCommitments (%v)",
fixedResource.HasCapacity, fixedResource.HandlesCommitments)

// Test Cores resource: hw_version_hana_fixed_cores
coresResource, ok := serviceInfo.Resources["hw_version_hana_fixed_cores"]
if !ok {
t.Fatal("expected hw_version_hana_fixed_cores resource to exist")
}
if !coresResource.HasCapacity {
t.Error("hw_version_hana_fixed_cores: expected HasCapacity=true")
}
if coresResource.HandlesCommitments {
t.Error("hw_version_hana_fixed_cores: expected HandlesCommitments=false (cores are derived)")
}

// Test variable ratio group: hw_version_v2_variable_ram
variableResource, ok := serviceInfo.Resources["hw_version_v2_variable_ram"]
// Test Instances resource: hw_version_hana_fixed_instances
instancesResource, ok := serviceInfo.Resources["hw_version_hana_fixed_instances"]
if !ok {
t.Fatal("expected hw_version_v2_variable_ram resource to exist")
t.Fatal("expected hw_version_hana_fixed_instances 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("hw_version_v2_variable_ram: expected HasCapacity=false (variable ratio groups don't report capacity)")
if !instancesResource.HasCapacity {
t.Error("hw_version_hana_fixed_instances: expected HasCapacity=true")
}
if instancesResource.HandlesCommitments {
t.Error("hw_version_hana_fixed_instances: expected HandlesCommitments=false (instances are derived)")
}

// Variable ratio group should NOT have any resources (skipped entirely)
if _, ok := serviceInfo.Resources["hw_version_v2_variable_ram"]; ok {
t.Error("hw_version_v2_variable_ram should NOT exist (variable ratio groups are skipped)")
}
if variableResource.HandlesCommitments {
t.Error("hw_version_v2_variable_ram: expected HandlesCommitments=false (variable ratio group)")
if _, ok := serviceInfo.Resources["hw_version_v2_variable_cores"]; ok {
t.Error("hw_version_v2_variable_cores should NOT exist (variable ratio groups are skipped)")
}
// Verify HasCapacity == HandlesCommitments for consistency
if variableResource.HasCapacity != variableResource.HandlesCommitments {
t.Errorf("hw_version_v2_variable_ram: HasCapacity (%v) should equal HandlesCommitments (%v)",
variableResource.HasCapacity, variableResource.HandlesCommitments)
if _, ok := serviceInfo.Resources["hw_version_v2_variable_instances"]; ok {
t.Error("hw_version_v2_variable_instances should NOT exist (variable ratio groups are skipped)")
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -183,18 +183,36 @@ func TestCapacityCalculator(t *testing.T) {
t.Fatalf("Expected no error, got: %v", err)
}

if len(report.Resources) != 1 {
t.Fatalf("Expected 1 resource, got %d", len(report.Resources))
// 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))
}

resource := report.Resources[liquid.ResourceName("hw_version_test-group_ram")]
if resource == nil {
// 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")
}
if len(ramResource.PerAZ) != 0 {
t.Errorf("Expected 0 AZs for RAM resource, got %d", len(ramResource.PerAZ))
}

// Should have empty perAZ map when no host details
if len(resource.PerAZ) != 0 {
t.Errorf("Expected 0 AZs, got %d", len(resource.PerAZ))
// 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")
}
if len(coresResource.PerAZ) != 0 {
t.Errorf("Expected 0 AZs for Cores resource, got %d", len(coresResource.PerAZ))
}

// 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")
}
if len(instancesResource.PerAZ) != 0 {
t.Errorf("Expected 0 AZs for Instances resource, got %d", len(instancesResource.PerAZ))
}
})
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -656,12 +656,22 @@ func verifyUsageReport(t *testing.T, tc UsageReportTestCase, actual liquid.Servi
t.Helper()

for resourceName, expectedResource := range tc.Expected {
// The test uses _ram resources in Expected, but:
// - _ram resource has usage but NO subresources
// - _instances resource has usage (count) AND subresources (VM details)
// So we check _ram for usage and derive _instances for VM subresources

actualResource, exists := actual.Resources[liquid.ResourceName(resourceName)]
if !exists {
t.Errorf("Resource %s not found in response", resourceName)
continue
}

// Derive the instances resource name from the ram resource name
// hw_version_hana_1_ram -> hw_version_hana_1_instances
instancesResourceName := resourceName[:len(resourceName)-4] + "_instances" // replace "_ram" with "_instances"
actualInstancesResource := actual.Resources[liquid.ResourceName(instancesResourceName)]

for azName, expectedAZ := range expectedResource.PerAZ {
az := liquid.AvailabilityZone(azName)
actualAZ, exists := actualResource.PerAZ[az]
Expand All @@ -670,22 +680,33 @@ func verifyUsageReport(t *testing.T, tc UsageReportTestCase, actual liquid.Servi
continue
}

// Verify usage
// Verify RAM usage
if actualAZ.Usage != expectedAZ.Usage {
t.Errorf("Resource %s AZ %s: expected usage %d, got %d",
resourceName, azName, expectedAZ.Usage, actualAZ.Usage)
}

// VM subresources are on the _instances resource, not _ram
if actualInstancesResource == nil {
t.Errorf("Instances resource %s not found", instancesResourceName)
continue
}
actualInstancesAZ, exists := actualInstancesResource.PerAZ[az]
if !exists {
t.Errorf("AZ %s not found in instances resource %s", azName, instancesResourceName)
continue
}

// Verify VM count
if len(actualAZ.Subresources) != len(expectedAZ.VMs) {
if len(actualInstancesAZ.Subresources) != len(expectedAZ.VMs) {
t.Errorf("Resource %s AZ %s: expected %d VMs, got %d",
resourceName, azName, len(expectedAZ.VMs), len(actualAZ.Subresources))
instancesResourceName, azName, len(expectedAZ.VMs), len(actualInstancesAZ.Subresources))
continue
}

// Build actual VM map for comparison (parse attributes)
actualVMs := make(map[string]vmAttributes)
for _, sub := range actualAZ.Subresources {
for _, sub := range actualInstancesAZ.Subresources {
var attrs vmAttributes
attrs.ID = sub.ID
if err := json.Unmarshal(sub.Attributes, &attrs); err != nil {
Expand All @@ -699,25 +720,25 @@ func verifyUsageReport(t *testing.T, tc UsageReportTestCase, actual liquid.Servi
for _, expectedVM := range expectedAZ.VMs {
actualVM, exists := actualVMs[expectedVM.UUID]
if !exists {
t.Errorf("Resource %s AZ %s: VM %s not found", resourceName, azName, expectedVM.UUID)
t.Errorf("Resource %s AZ %s: VM %s not found", instancesResourceName, azName, expectedVM.UUID)
continue
}

// Verify commitment assignment
if actualVM.CommitmentID != expectedVM.CommitmentID {
if expectedVM.CommitmentID == "" {
t.Errorf("Resource %s AZ %s VM %s: expected PAYG (empty), got commitment %s",
resourceName, azName, expectedVM.UUID, actualVM.CommitmentID)
instancesResourceName, azName, expectedVM.UUID, actualVM.CommitmentID)
} else {
t.Errorf("Resource %s AZ %s VM %s: expected commitment %s, got %s",
resourceName, azName, expectedVM.UUID, expectedVM.CommitmentID, actualVM.CommitmentID)
instancesResourceName, azName, expectedVM.UUID, expectedVM.CommitmentID, actualVM.CommitmentID)
}
}

// Verify memory
if actualVM.RAM != expectedVM.MemoryMB {
t.Errorf("Resource %s AZ %s VM %s: expected RAM %d MB, got %d MB",
resourceName, azName, expectedVM.UUID, expectedVM.MemoryMB, actualVM.RAM)
instancesResourceName, azName, expectedVM.UUID, expectedVM.MemoryMB, actualVM.RAM)
}
}
}
Expand Down
Loading
Loading