diff --git a/api/v1alpha1/flavor_group_capacity_types.go b/api/v1alpha1/flavor_group_capacity_types.go index a7339dce2..80596256e 100644 --- a/api/v1alpha1/flavor_group_capacity_types.go +++ b/api/v1alpha1/flavor_group_capacity_types.go @@ -4,6 +4,7 @@ package v1alpha1 import ( + "k8s.io/apimachinery/pkg/api/resource" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) @@ -56,6 +57,10 @@ type FlavorGroupCapacityStatus struct { // +kubebuilder:validation:Optional CommittedCapacity int64 `json:"committedCapacity,omitempty"` + // TotalCapacity is the total capacity of all eligible hosts in an empty-datacenter scenario. + // +kubebuilder:validation:Optional + TotalCapacity map[string]resource.Quantity `json:"totalCapacity,omitempty"` + // TotalInstances is the total number of VM instances running on hypervisors in this AZ, // derived from Hypervisor CRD Status.Instances (not filtered by flavor group). // +kubebuilder:validation:Optional diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index 06f075e1e..081244ef8 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -849,6 +849,13 @@ func (in *FlavorGroupCapacityStatus) DeepCopyInto(out *FlavorGroupCapacityStatus *out = make([]FlavorCapacityStatus, len(*in)) copy(*out, *in) } + if in.TotalCapacity != nil { + in, out := &in.TotalCapacity, &out.TotalCapacity + *out = make(map[string]resource.Quantity, len(*in)) + for key, val := range *in { + (*out)[key] = val.DeepCopy() + } + } in.LastReconcileAt.DeepCopyInto(&out.LastReconcileAt) if in.Conditions != nil { in, out := &in.Conditions, &out.Conditions diff --git a/helm/library/cortex/files/crds/cortex.cloud_flavorgroupcapacities.yaml b/helm/library/cortex/files/crds/cortex.cloud_flavorgroupcapacities.yaml index 5f475689e..11958bfda 100644 --- a/helm/library/cortex/files/crds/cortex.cloud_flavorgroupcapacities.yaml +++ b/helm/library/cortex/files/crds/cortex.cloud_flavorgroupcapacities.yaml @@ -174,6 +174,16 @@ spec: reconcile. format: date-time type: string + totalCapacity: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: TotalCapacity is the total capacity of all eligible hosts + in an empty-datacenter scenario. + type: object totalInstances: description: |- TotalInstances is the total number of VM instances running on hypervisors in this AZ, diff --git a/internal/knowledge/extractor/plugins/compute/flavor_groups.go b/internal/knowledge/extractor/plugins/compute/flavor_groups.go index 1dacb9d38..fd5d31b48 100644 --- a/internal/knowledge/extractor/plugins/compute/flavor_groups.go +++ b/internal/knowledge/extractor/plugins/compute/flavor_groups.go @@ -35,7 +35,7 @@ type FlavorGroupFeature struct { // The largest flavor in the group (used for reservation slot sizing) LargestFlavor FlavorInGroup `json:"largestFlavor"` - // The smallest flavor in the group (used for CR size quantification) + // The smallest flavor in the group SmallestFlavor FlavorInGroup `json:"smallestFlavor"` // RAM-to-core ratio in MiB per vCPU (MemoryMB / VCPUs). diff --git a/internal/scheduling/reservations/capacity/controller.go b/internal/scheduling/reservations/capacity/controller.go index eba8a9fec..37b6da4ef 100644 --- a/internal/scheduling/reservations/capacity/controller.go +++ b/internal/scheduling/reservations/capacity/controller.go @@ -15,6 +15,7 @@ import ( "github.com/google/uuid" apierrors "k8s.io/apimachinery/pkg/api/errors" "k8s.io/apimachinery/pkg/api/meta" + "k8s.io/apimachinery/pkg/api/resource" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/types" ctrl "sigs.k8s.io/controller-runtime" @@ -172,10 +173,42 @@ func (c *Controller) reconcileOne( committedCapacity = 0 } + // Compute TotalCapacity: for each flavor multiply slot count by its RAM/CPU, + // then take the max across all flavors independently for each resource. + // This reveals the most capacity because the flavor best matching the host's + // resource ratio saturates more resources and produces a higher product. + flavorSpecByName := make(map[string]compute.FlavorInGroup, len(groupData.Flavors)) + for _, f := range groupData.Flavors { + flavorSpecByName[f.Name] = f + } + totalCapacity := make(map[string]resource.Quantity) + var maxMemBytes, maxCPUCores int64 + for _, f := range newFlavors { + spec, ok := flavorSpecByName[f.FlavorName] + if !ok || f.TotalCapacityVMSlots <= 0 { + continue + } + memBytes := f.TotalCapacityVMSlots * int64(spec.MemoryMB) * 1024 * 1024 //nolint:gosec + cpuCores := f.TotalCapacityVMSlots * int64(spec.VCPUs) //nolint:gosec + if memBytes > maxMemBytes { + maxMemBytes = memBytes + } + if cpuCores > maxCPUCores { + maxCPUCores = cpuCores + } + } + if maxMemBytes > 0 { + totalCapacity["memory"] = *resource.NewQuantity(maxMemBytes, resource.BinarySI) + } + if maxCPUCores > 0 { + totalCapacity["cpu"] = *resource.NewQuantity(maxCPUCores, resource.DecimalSI) + } + patch := client.MergeFrom(existing.DeepCopy()) existing.Status.Flavors = newFlavors existing.Status.TotalInstances = totalInstances existing.Status.CommittedCapacity = committedCapacity + existing.Status.TotalCapacity = totalCapacity existing.Status.LastReconcileAt = metav1.Now() freshCondition := metav1.Condition{ diff --git a/internal/scheduling/reservations/commitments/api/change_commitments.go b/internal/scheduling/reservations/commitments/api/change_commitments.go index fd821799b..baee3086c 100644 --- a/internal/scheduling/reservations/commitments/api/change_commitments.go +++ b/internal/scheduling/reservations/commitments/api/change_commitments.go @@ -190,8 +190,7 @@ ProcessLoop: break ProcessLoop } - flavorGroup, ok := flavorGroups[flavorGroupName] - if !ok { + if _, ok := flavorGroups[flavorGroupName]; !ok { failedReason = "flavor group not found: " + flavorGroupName rollback = true break ProcessLoop @@ -249,7 +248,7 @@ ProcessLoop: } stateDesired, err := commitments.FromChangeCommitmentTargetState( - commitment, string(projectID), domainID, flavorGroupName, flavorGroup, string(req.AZ)) + commitment, string(projectID), domainID, flavorGroupName, string(req.AZ)) if err != nil { failedReason = fmt.Sprintf("commitment %s: %s", commitment.UUID, err) rollback = true diff --git a/internal/scheduling/reservations/commitments/api/info.go b/internal/scheduling/reservations/commitments/api/info.go index c9576008f..d8459c50b 100644 --- a/internal/scheduling/reservations/commitments/api/info.go +++ b/internal/scheduling/reservations/commitments/api/info.go @@ -6,7 +6,6 @@ package api import ( "context" "encoding/json" - "errors" "fmt" "net/http" "strconv" @@ -20,9 +19,6 @@ import ( liquid "github.com/sapcc/go-api-declarations/liquid" ) -// errInternalServiceInfo indicates an internal error while building service info (e.g., invalid unit configuration) -var errInternalServiceInfo = errors.New("internal error building service info") - // handles GET /commitments/v1/info requests from Limes: // See: https://github.com/sapcc/go-api-declarations/blob/main/liquid/commitment.go // See: https://pkg.go.dev/github.com/sapcc/go-api-declarations/liquid @@ -54,16 +50,9 @@ func (api *HTTPAPI) HandleInfo(w http.ResponseWriter, r *http.Request) { // Build info response info, err := api.buildServiceInfo(ctx, logger) if err != nil { - if errors.Is(err, errInternalServiceInfo) { - logger.Error(err, "internal error building service info") - statusCode = http.StatusInternalServerError - http.Error(w, "Internal server error: "+err.Error(), statusCode) - } else { - // Use Info level for expected conditions like knowledge not being ready yet - logger.Info("service info not available yet", "error", err.Error()) - statusCode = http.StatusServiceUnavailable - http.Error(w, "Service temporarily unavailable: "+err.Error(), statusCode) - } + logger.Info("service info not available yet", "error", err.Error()) + statusCode = http.StatusServiceUnavailable + http.Error(w, "Service temporarily unavailable: "+err.Error(), statusCode) api.recordInfoMetrics(statusCode, startTime) return } @@ -133,20 +122,8 @@ func (api *HTTPAPI) buildServiceInfo(ctx context.Context, logger logr.Logger) (l attrsJSON = nil } - // 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) - } - // === 1. RAM Resource === ramResourceName := liquid.ResourceName(commitments.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) - } // Determine topology: AZSeparatedTopology only for groups that accept commitments // (AZSeparatedTopology means quota is also AZ-aware, required when HasQuota=true) ramTopology := liquid.AZAwareTopology @@ -155,11 +132,10 @@ func (api *HTTPAPI) buildServiceInfo(ctx context.Context, logger logr.Logger) (l } resources[ramResourceName] = liquid.ResourceInfo{ DisplayName: fmt.Sprintf( - "multiples of %d MiB (usable by: %s)", - groupData.SmallestFlavor.MemoryMB, + "GiB of RAM (usable by: %s)", flavorListStr, ), - Unit: ramUnit, + Unit: liquid.UnitGibibytes, Topology: ramTopology, NeedsResourceDemand: false, HasCapacity: resCfg.RAM.HasCapacity, @@ -205,8 +181,6 @@ func (api *HTTPAPI) buildServiceInfo(ctx context.Context, logger logr.Logger) (l "ramResource", ramResourceName, "coresResource", coresResourceName, "instancesResource", instancesResourceName, - "smallestFlavor", groupData.SmallestFlavor.Name, - "smallestRamMB", groupData.SmallestFlavor.MemoryMB, "ramCoreRatio", groupData.RamCoreRatio) } diff --git a/internal/scheduling/reservations/commitments/api/info_test.go b/internal/scheduling/reservations/commitments/api/info_test.go index 514ebc752..e74964925 100644 --- a/internal/scheduling/reservations/commitments/api/info_test.go +++ b/internal/scheduling/reservations/commitments/api/info_test.go @@ -78,16 +78,15 @@ func TestHandleInfo_MethodNotAllowed(t *testing.T) { } func TestHandleInfo_InvalidFlavorMemory(t *testing.T) { - // Test that a 500 Internal Server Error is returned when a flavor group has invalid data. - // - // A flavor with memoryMB=0 is invalid and should trigger an HTTP 500 error. - // Such data could occur from a bug in the flavor groups extractor. + // Test that the info endpoint succeeds even when a flavor group has memoryMB=0. + // With the fixed GiB unit, we no longer reject zero-memory flavors at the info level; + // they result in zero capacity at the capacity reporting level instead. scheme := runtime.NewScheme() if err := v1alpha1.AddToScheme(scheme); err != nil { t.Fatalf("failed to add scheme: %v", err) } - // Create flavor group with memoryMB=0 (invalid data that could come from a buggy extractor) + // Create flavor group with memoryMB=0 (edge case from a buggy extractor) features := []map[string]interface{}{ { "name": "invalid_group", @@ -132,9 +131,9 @@ func TestHandleInfo_InvalidFlavorMemory(t *testing.T) { resp := w.Result() defer resp.Body.Close() - // Should return 500 Internal Server Error when unit creation fails - if resp.StatusCode != http.StatusInternalServerError { - t.Errorf("expected status code %d (Internal Server Error), got %d", http.StatusInternalServerError, resp.StatusCode) + // Should return 200 OK — zero-memory flavor no longer causes an error + if resp.StatusCode != http.StatusOK { + t.Errorf("expected status code %d (OK), got %d", http.StatusOK, resp.StatusCode) } } diff --git a/internal/scheduling/reservations/commitments/api/report_capacity.go b/internal/scheduling/reservations/commitments/api/report_capacity.go index 9f0966cce..ec537607a 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 := commitments.NewCapacityCalculator(api.client) + calculator := commitments.NewCapacityCalculator(api.client, api.config) report, err := calculator.CalculateCapacity(ctx, req) if err != nil { logger.Error(err, "failed to calculate capacity") diff --git a/internal/scheduling/reservations/commitments/api/report_capacity_test.go b/internal/scheduling/reservations/commitments/api/report_capacity_test.go index 9972c6681..638c060db 100644 --- a/internal/scheduling/reservations/commitments/api/report_capacity_test.go +++ b/internal/scheduling/reservations/commitments/api/report_capacity_test.go @@ -29,6 +29,18 @@ func TestHandleReportCapacity(t *testing.T) { t.Fatal(err) } + // testCapacityConfig enables capacity reporting for all groups via "*" catch-all. + testCapacityConfig := commitments.APIConfig{ + EnableReportCapacity: true, + FlavorGroupResourceConfig: map[string]commitments.FlavorGroupResourcesConfig{ + "*": { + RAM: commitments.ResourceTypeConfig{HasCapacity: true}, + Cores: commitments.ResourceTypeConfig{HasCapacity: true}, + Instances: commitments.ResourceTypeConfig{HasCapacity: true}, + }, + }, + } + // Create empty flavor groups knowledge so capacity calculation doesn't fail emptyKnowledge := createEmptyFlavorGroupKnowledge() @@ -37,7 +49,7 @@ func TestHandleReportCapacity(t *testing.T) { WithObjects(emptyKnowledge). Build() - api := NewAPI(fakeClient) + api := NewAPIWithConfig(fakeClient, testCapacityConfig, nil) tests := []struct { name string @@ -131,12 +143,22 @@ func TestCapacityCalculator(t *testing.T) { t.Fatal(err) } + testCapacityConfig := commitments.APIConfig{ + FlavorGroupResourceConfig: map[string]commitments.FlavorGroupResourcesConfig{ + "*": { + RAM: commitments.ResourceTypeConfig{HasCapacity: true}, + Cores: commitments.ResourceTypeConfig{HasCapacity: true}, + Instances: commitments.ResourceTypeConfig{HasCapacity: true}, + }, + }, + } + t.Run("CalculateCapacity returns error when no flavor groups knowledge exists", func(t *testing.T) { fakeClient := fake.NewClientBuilder(). WithScheme(scheme). Build() - calculator := commitments.NewCapacityCalculator(fakeClient) + calculator := commitments.NewCapacityCalculator(fakeClient, testCapacityConfig) req := liquid.ServiceCapacityRequest{ AllAZs: []liquid.AvailabilityZone{"az-one", "az-two"}, } @@ -158,7 +180,7 @@ func TestCapacityCalculator(t *testing.T) { WithObjects(emptyKnowledge). Build() - calculator := commitments.NewCapacityCalculator(fakeClient) + calculator := commitments.NewCapacityCalculator(fakeClient, testCapacityConfig) req := liquid.ServiceCapacityRequest{ AllAZs: []liquid.AvailabilityZone{"az-one", "az-two"}, } @@ -183,7 +205,7 @@ func TestCapacityCalculator(t *testing.T) { WithObjects(flavorGroupKnowledge). Build() - calculator := commitments.NewCapacityCalculator(fakeClient) + calculator := commitments.NewCapacityCalculator(fakeClient, testCapacityConfig) req := liquid.ServiceCapacityRequest{ AllAZs: []liquid.AvailabilityZone{"qa-de-1a", "qa-de-1b", "qa-de-1d"}, } @@ -209,7 +231,7 @@ func TestCapacityCalculator(t *testing.T) { WithObjects(flavorGroupKnowledge). Build() - calculator := commitments.NewCapacityCalculator(fakeClient) + calculator := commitments.NewCapacityCalculator(fakeClient, testCapacityConfig) req := liquid.ServiceCapacityRequest{AllAZs: []liquid.AvailabilityZone{}} report, err := calculator.CalculateCapacity(context.Background(), req) if err != nil { @@ -234,7 +256,7 @@ func TestCapacityCalculator(t *testing.T) { WithObjects(flavorGroupKnowledge). Build() - calculator := commitments.NewCapacityCalculator(fakeClient) + calculator := commitments.NewCapacityCalculator(fakeClient, testCapacityConfig) req1 := liquid.ServiceCapacityRequest{ AllAZs: []liquid.AvailabilityZone{"eu-de-1a", "eu-de-1b"}, @@ -270,7 +292,7 @@ func TestCapacityCalculator(t *testing.T) { WithStatusSubresource(crd). Build() - calculator := commitments.NewCapacityCalculator(fakeClient) + calculator := commitments.NewCapacityCalculator(fakeClient, testCapacityConfig) req := liquid.ServiceCapacityRequest{AllAZs: []liquid.AvailabilityZone{"az-one"}} report, err := calculator.CalculateCapacity(context.Background(), req) if err != nil { @@ -285,15 +307,15 @@ func TestCapacityCalculator(t *testing.T) { if azReport == nil { t.Fatal("expected az-one entry") } - if azReport.Capacity != 1000 { - t.Errorf("expected capacity=1000, got %d", azReport.Capacity) + if azReport.Capacity != 32000 { + t.Errorf("expected capacity=32000, got %d", azReport.Capacity) } if !azReport.Usage.IsSome() { t.Fatal("expected usage to be set for Ready CRD") } - // usage = capacity - placeable = 1000 - 800 = 200 - if usage := azReport.Usage.UnwrapOr(0); usage != 200 { - t.Errorf("expected usage=200 (1000-800), got %d", usage) + // usage = (capacity - placeable) * 32 GiB/slot = (1000 - 800) * 32 = 6400 + if usage := azReport.Usage.UnwrapOr(0); usage != 6400 { + t.Errorf("expected usage=6400 (200*32), got %d", usage) } }) @@ -307,7 +329,7 @@ func TestCapacityCalculator(t *testing.T) { WithStatusSubresource(crd). Build() - calculator := commitments.NewCapacityCalculator(fakeClient) + calculator := commitments.NewCapacityCalculator(fakeClient, testCapacityConfig) req := liquid.ServiceCapacityRequest{AllAZs: []liquid.AvailabilityZone{"az-one", "az-two"}} report, err := calculator.CalculateCapacity(context.Background(), req) if err != nil { @@ -336,7 +358,7 @@ func TestCapacityCalculator(t *testing.T) { WithStatusSubresource(crd). Build() - calculator := commitments.NewCapacityCalculator(fakeClient) + calculator := commitments.NewCapacityCalculator(fakeClient, testCapacityConfig) req := liquid.ServiceCapacityRequest{AllAZs: []liquid.AvailabilityZone{"az-one"}} report, err := calculator.CalculateCapacity(context.Background(), req) if err != nil { @@ -351,9 +373,9 @@ func TestCapacityCalculator(t *testing.T) { if azReport == nil { t.Fatal("expected az-one entry") } - // Stale CRD: last-known capacity is still reported - if azReport.Capacity != 1000 { - t.Errorf("expected last-known capacity=1000 for stale CRD, got %d", azReport.Capacity) + // Stale CRD: last-known capacity is still reported (1000 slots * 32 GiB/slot) + if azReport.Capacity != 32000 { + t.Errorf("expected last-known capacity=32000 for stale CRD, got %d", azReport.Capacity) } // Stale CRD: usage must be absent (None) if azReport.Usage.IsSome() { @@ -463,20 +485,20 @@ func createTestFlavorGroupKnowledge(t *testing.T) *v1alpha1.Knowledge { { "name": "test_c8_m32", "vcpus": 8, - "memoryMB": 32768, + "memoryMB": 32752, "diskGB": 50, }, }, "largestFlavor": map[string]interface{}{ "name": "test_c8_m32", "vcpus": 8, - "memoryMB": 32768, + "memoryMB": 32752, "diskGB": 50, }, "smallestFlavor": map[string]interface{}{ "name": "test_c8_m32", "vcpus": 8, - "memoryMB": 32768, + "memoryMB": 32752, "diskGB": 50, }, // Fixed RAM/core ratio (4096 MiB per vCPU) - required for group to accept commitments diff --git a/internal/scheduling/reservations/commitments/api/report_usage_test.go b/internal/scheduling/reservations/commitments/api/report_usage_test.go index a867122ff..889aeeabd 100644 --- a/internal/scheduling/reservations/commitments/api/report_usage_test.go +++ b/internal/scheduling/reservations/commitments/api/report_usage_test.go @@ -44,9 +44,9 @@ func TestReportUsageIntegration(t *testing.T) { m1Large := &TestFlavor{Name: "m1.large", Group: "hana_1", MemoryMB: 4096, VCPUs: 16} // 4 units m1XL := &TestFlavor{Name: "m1.xl", Group: "hana_1", MemoryMB: 8192, VCPUs: 32} // 8 units - // gp_1 group: smallest = 512 MB, so 1 unit = 0.5 GB - gpSmall := &TestFlavor{Name: "gp.small", Group: "gp_1", MemoryMB: 512, VCPUs: 1} // 1 unit - gpMedium := &TestFlavor{Name: "gp.medium", Group: "gp_1", MemoryMB: 2048, VCPUs: 4} // 4 units + // gp_1 group: smallest = 1024 MB = 1 GiB, so 1 unit = 1 GiB + gpSmall := &TestFlavor{Name: "gp.small", Group: "gp_1", MemoryMB: 1024, VCPUs: 1} // 1 unit + gpMedium := &TestFlavor{Name: "gp.medium", Group: "gp_1", MemoryMB: 2048, VCPUs: 4} // 2 units baseTime := time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC) @@ -290,7 +290,7 @@ func TestReportUsageIntegration(t *testing.T) { "hw_version_gp_1_ram": { PerAZ: map[string]ExpectedAZUsage{ "az-a": { - Usage: 4, // 2048 MB / 512 MB = 4 units + Usage: 2, // 2048 MB / 1024 MB = 2 units VMs: []ExpectedVMUsage{ {UUID: "vm-gp", CommitmentID: "commit-gp", MemoryMB: 2048}, }, @@ -354,11 +354,11 @@ func TestReportUsageIntegration(t *testing.T) { ExpectedStatusCode: http.StatusMethodNotAllowed, }, { - Name: "VM with empty AZ - normalized to unknown", + Name: "VM with empty AZ - dropped from report", ProjectID: "project-empty-az", Flavors: []*TestFlavor{m1Small, m1Large}, VMs: []*TestVMUsage{ - // VM with empty AZ (e.g., ERROR or BUILDING state VM not yet scheduled) + // VM with empty AZ (e.g., ERROR or BUILDING state) — normalized to "unknown", excluded. newTestVMUsageWithEmptyAZ("vm-error", m1Large, "project-empty-az", "host-1", baseTime), // Normal VM with valid AZ newTestVMUsage("vm-ok", m1Large, "project-empty-az", "az-a", "host-2", baseTime.Add(1*time.Hour)), @@ -377,10 +377,98 @@ func TestReportUsageIntegration(t *testing.T) { {UUID: "vm-ok", CommitmentID: "commit-1", MemoryMB: 4096}, }, }, - "unknown": { - Usage: 4, // VM with empty AZ normalized to "unknown" + // "unknown" AZ is excluded — VMs without a valid AZ are dropped. + }, + }, + }, + }, + { + // hana_1 has a fixed RAM/core ratio (all flavors: 256 MiB/vCPU), so _ram + // is AZSeparatedTopology and carries per-AZ quota. This test verifies that + // the per-AZ quota value is read from the ProjectQuota CRD when present. + Name: "Fixed-ratio group with ProjectQuota CRD - quota reported per AZ", + ProjectID: "project-quota", + Flavors: []*TestFlavor{m1Small, m1Large}, + VMs: []*TestVMUsage{ + newTestVMUsage("vm-001", m1Large, "project-quota", "az-a", "host-1", baseTime), + }, + Reservations: []*UsageTestReservation{ + {CommitmentID: "commit-1", Flavor: m1Small, ProjectID: "project-quota", AZ: "az-a", Count: 4}, + }, + ProjectQuota: &v1alpha1.ProjectQuota{ + ObjectMeta: metav1.ObjectMeta{Name: "quota-project-quota"}, + Spec: v1alpha1.ProjectQuotaSpec{ + ProjectID: "project-quota", + DomainID: "test-domain", + Quota: map[string]v1alpha1.ResourceQuota{ + "hw_version_hana_1_ram": { + Quota: 16, + PerAZ: map[string]int64{"az-a": 16}, + }, + }, + }, + }, + AllAZs: []string{"az-a"}, + Expected: map[string]ExpectedResourceUsage{ + "hw_version_hana_1_ram": { + PerAZ: map[string]ExpectedAZUsage{ + "az-a": { + Usage: 4, + Quota: func() *int64 { v := int64(16); return &v }(), VMs: []ExpectedVMUsage{ - {UUID: "vm-error", CommitmentID: "", MemoryMB: 4096}, // PAYG - no commitment in "unknown" AZ + {UUID: "vm-001", CommitmentID: "commit-1", MemoryMB: 4096}, + }, + }, + }, + }, + }, + }, + { + // When no ProjectQuota CRD exists for a project, quota defaults to -1 (infinite). + Name: "Fixed-ratio group with no ProjectQuota CRD - infinite quota", + ProjectID: "project-no-quota", + Flavors: []*TestFlavor{m1Small, m1Large}, + VMs: []*TestVMUsage{ + newTestVMUsage("vm-001", m1Large, "project-no-quota", "az-a", "host-1", baseTime), + }, + Reservations: []*UsageTestReservation{ + {CommitmentID: "commit-1", Flavor: m1Small, ProjectID: "project-no-quota", AZ: "az-a", Count: 4}, + }, + AllAZs: []string{"az-a"}, + Expected: map[string]ExpectedResourceUsage{ + "hw_version_hana_1_ram": { + PerAZ: map[string]ExpectedAZUsage{ + "az-a": { + Usage: 4, + Quota: func() *int64 { v := int64(-1); return &v }(), + VMs: []ExpectedVMUsage{ + {UUID: "vm-001", CommitmentID: "commit-1", MemoryMB: 4096}, + }, + }, + }, + }, + }, + }, + { + // gp_1 has a variable RAM/core ratio (gpSmall=1024 MiB/vCPU, gpMedium=512 MiB/vCPU), + // so _ram is NOT AZSeparatedTopology and must carry no quota field. + Name: "Variable-ratio group - no quota field on _ram resource", + ProjectID: "project-variable", + Flavors: []*TestFlavor{gpSmall, gpMedium}, + VMs: []*TestVMUsage{ + newTestVMUsage("vm-001", gpMedium, "project-variable", "az-a", "host-1", baseTime), + }, + Reservations: []*UsageTestReservation{}, + AllAZs: []string{"az-a"}, + Expected: map[string]ExpectedResourceUsage{ + "hw_version_gp_1_ram": { + PerAZ: map[string]ExpectedAZUsage{ + // AssertNoQuota: quota field must be absent for variable-ratio groups. + "az-a": { + Usage: 2, + AssertNoQuota: true, + VMs: []ExpectedVMUsage{ + {UUID: "vm-001", CommitmentID: "", MemoryMB: 2048}, }, }, }, @@ -407,6 +495,7 @@ type UsageReportTestCase struct { Flavors []*TestFlavor VMs []*TestVMUsage Reservations []*UsageTestReservation + ProjectQuota *v1alpha1.ProjectQuota // optional; nil means no quota CRD present AllAZs []string Expected map[string]ExpectedResourceUsage ExpectedStatusCode int // 0 means expect 200 OK @@ -459,8 +548,10 @@ type ExpectedResourceUsage struct { } type ExpectedAZUsage struct { - Usage uint64 // Usage in multiples of smallest flavor - VMs []ExpectedVMUsage + Usage uint64 // Usage in multiples of smallest flavor + Quota *int64 // non-nil: verify this exact value (-1 = infinite) + AssertNoQuota bool // true: verify quota field is absent + VMs []ExpectedVMUsage } type ExpectedVMUsage struct { @@ -541,6 +632,7 @@ func newUsageTestEnv( vms []*TestVMUsage, reservations []*UsageTestReservation, flavorGroups FlavorGroupsKnowledge, + projectQuota *v1alpha1.ProjectQuota, ) *UsageTestEnv { t.Helper() @@ -584,9 +676,14 @@ func newUsageTestEnv( } k8sReservations = append(k8sReservations, crObjects...) + + builderObjs := k8sReservations + if projectQuota != nil { + builderObjs = append(builderObjs, projectQuota) + } k8sClient := fake.NewClientBuilder(). WithScheme(scheme). - WithObjects(k8sReservations...). + WithObjects(builderObjs...). WithStatusSubresource(&v1alpha1.Reservation{}). WithStatusSubresource(&v1alpha1.Knowledge{}). WithStatusSubresource(&v1alpha1.CommittedResource{}). @@ -608,6 +705,13 @@ func newUsageTestEnv( } return []string{cr.Spec.ProjectID} }). + WithIndex(&v1alpha1.ProjectQuota{}, "spec.projectID", func(obj client.Object) []string { + pq, ok := obj.(*v1alpha1.ProjectQuota) + if !ok || pq.Spec.ProjectID == "" { + return nil + } + return []string{pq.Spec.ProjectID} + }). Build() // Create mock DB client with VMs @@ -726,7 +830,7 @@ func runUsageReportTest(t *testing.T, tc UsageReportTestCase) { }.ToFlavorGroupsKnowledge() // Create test environment - env := newUsageTestEnv(t, tc.VMs, tc.Reservations, flavorGroups) + env := newUsageTestEnv(t, tc.VMs, tc.Reservations, flavorGroups, tc.ProjectQuota) defer env.Close() // Call API @@ -785,6 +889,25 @@ func verifyUsageReport(t *testing.T, tc UsageReportTestCase, actual liquid.Servi resourceName, azName, expectedAZ.Usage, actualAZ.Usage) } + // Verify per-AZ quota when the test case specifies it. + if expectedAZ.Quota != nil { + actualQuota, hasQuota := actualAZ.Quota.Unpack() + if !hasQuota { + t.Errorf("Resource %s AZ %s: expected quota %d but quota field is absent", + resourceName, azName, *expectedAZ.Quota) + } else if actualQuota != *expectedAZ.Quota { + t.Errorf("Resource %s AZ %s: expected quota %d, got %d", + resourceName, azName, *expectedAZ.Quota, actualQuota) + } + } + if expectedAZ.AssertNoQuota { + if actualAZ.Quota.IsSome() { + v, _ := actualAZ.Quota.Unpack() + t.Errorf("Resource %s AZ %s: expected no quota field, got %d", + resourceName, azName, v) + } + } + // VM subresources are on the _instances resource, not _ram if actualInstancesResource == nil { t.Errorf("Instances resource %s not found", instancesResourceName) diff --git a/internal/scheduling/reservations/commitments/api/usage_test.go b/internal/scheduling/reservations/commitments/api/usage_test.go index d15967d16..667567bf1 100644 --- a/internal/scheduling/reservations/commitments/api/usage_test.go +++ b/internal/scheduling/reservations/commitments/api/usage_test.go @@ -437,10 +437,9 @@ func TestUsageCalculator_ExpiredAndFutureCommitments(t *testing.T) { } // TestUsageMultipleCalculation_FloorDivision tests that RAM usage is calculated -// using floor division to handle Nova's memory overhead correctly. -// Nova flavors like "2 GiB" actually have 2032 MiB (not 2048) due to overhead. -// A "4 GiB" flavor has 4080 MiB, which is 2.007× the base unit. -// Floor division ensures 4080 / 2032 = 2 (not 3 from ceiling). +// by adding the 16 MiB video RAM reservation before dividing, matching actual flavor sizing. +// Nova flavors like "4 GiB" have 4080 MiB (4096 - 16 for hw_video:ram_max_mb=16). +// Adding 16 MiB restores the exact GiB multiple before integer division. func TestUsageMultipleCalculation_FloorDivision(t *testing.T) { log.SetLogger(zap.New(zap.WriteTo(os.Stderr), zap.UseDevMode(true))) ctx := context.Background() @@ -461,7 +460,7 @@ func TestUsageMultipleCalculation_FloorDivision(t *testing.T) { expectedInstances uint64 }{ { - name: "single smallest flavor - 1 unit", + name: "single smallest flavor - 2 units", vms: []commitments.VMRow{ { ID: "vm-001", Name: "vm-001", Status: "ACTIVE", @@ -470,12 +469,12 @@ func TestUsageMultipleCalculation_FloorDivision(t *testing.T) { FlavorName: "g_k_c1_m2_v2", FlavorRAM: 2032, FlavorVCPUs: 1, }, }, - expectedRAM: 1, + expectedRAM: 2, expectedCores: 1, expectedInstances: 1, }, { - name: "2x flavor with overhead - floor(4080/2032) = 2 units, not 3", + name: "2x flavor with overhead - (4080+16)/1024 = 4 GiB", vms: []commitments.VMRow{ { ID: "vm-001", Name: "vm-001", Status: "ACTIVE", @@ -484,7 +483,7 @@ func TestUsageMultipleCalculation_FloorDivision(t *testing.T) { FlavorName: "g_k_c2_m4_v2", FlavorRAM: 4080, FlavorVCPUs: 2, }, }, - expectedRAM: 2, // floor(4080/2032) = 2, NOT 3 (ceiling would give 3) + expectedRAM: 4, // (4080+16)/1024 = 4 expectedCores: 2, expectedInstances: 1, }, @@ -516,12 +515,10 @@ func TestUsageMultipleCalculation_FloorDivision(t *testing.T) { FlavorName: "g_k_c16_m32_v2", FlavorRAM: 32752, FlavorVCPUs: 16, }, }, - // floor(2032/2032) + floor(4080/2032) + floor(16368/2032) + floor(32752/2032) - // = 1 + 2 + 8 + 16 = 27 (matches sum of vCPUs: 1+2+4+16=23... wait, that's not right) - // Actually cores = 1+2+4+16 = 23 - // RAM units = 1+2+8+16 = 27 - // These don't match because vCPUs and RAM have different ratios per flavor! - expectedRAM: 27, // 1 + 2 + 8 + 16 + // (2032+16)/1024 + (4080+16)/1024 + (16368+16)/1024 + (32752+16)/1024 + // = 2 + 4 + 16 + 32 = 54 + // Cores: 1 + 2 + 4 + 16 = 23 + expectedRAM: 54, // 2 + 4 + 16 + 32 expectedCores: 23, // 1 + 2 + 4 + 16 expectedInstances: 4, }, diff --git a/internal/scheduling/reservations/commitments/capacity.go b/internal/scheduling/reservations/commitments/capacity.go index 076428fa6..1ab5c62d2 100644 --- a/internal/scheduling/reservations/commitments/capacity.go +++ b/internal/scheduling/reservations/commitments/capacity.go @@ -19,15 +19,19 @@ import ( // CapacityCalculator computes capacity reports for Limes LIQUID API. type CapacityCalculator struct { client client.Client + conf APIConfig } -func NewCapacityCalculator(client client.Client) *CapacityCalculator { - return &CapacityCalculator{client: client} +func NewCapacityCalculator(client client.Client, conf APIConfig) *CapacityCalculator { + return &CapacityCalculator{client: client, conf: conf} } // CalculateCapacity computes per-AZ capacity for all flavor groups. // For each flavor group, three resources are reported: _ram, _cores, _instances. // Capacity and usage are read from FlavorGroupCapacity CRDs pre-computed by the capacity controller. +// Usage is approximated from slot counts (total − placeable of the smallest flavor); this may +// slightly under-report usage when larger flavors are running, showing more free capacity than +// reality — acceptable for capacity planning purposes. func (c *CapacityCalculator) CalculateCapacity(ctx context.Context, req liquid.ServiceCapacityRequest) (liquid.ServiceCapacityReport, error) { // Get all flavor groups from Knowledge CRDs (needed for smallest-flavor lookup). knowledge := &reservations.FlavorGroupKnowledgeClient{Client: c.client} @@ -62,20 +66,35 @@ func (c *CapacityCalculator) CalculateCapacity(ctx context.Context, req liquid.S logger := LoggerFromContext(ctx) for groupName, groupData := range flavorGroups { + resCfg := c.conf.ResourceConfigForGroup(groupName) + // Skip groups not configured for capacity reporting. + if !resCfg.RAM.HasCapacity && !resCfg.Cores.HasCapacity && !resCfg.Instances.HasCapacity { + continue + } + smallestFlavorName := groupData.SmallestFlavor.Name + // Add 16 MiB before dividing: flavors reserve 16 MiB for video RAM (hw_video:ram_max_mb=16), + // so a nominal "2 GiB" flavor has 2032 MiB. + memoryMBPerSlot := groupData.SmallestFlavor.MemoryMB + 16 + vcpusPerSlot := groupData.SmallestFlavor.VCPUs + + ramAZCapacity := make(map[liquid.AvailabilityZone]*liquid.AZResourceCapacityReport, len(req.AllAZs)) + coresAZCapacity := make(map[liquid.AvailabilityZone]*liquid.AZResourceCapacityReport, len(req.AllAZs)) + instancesAZCapacity := make(map[liquid.AvailabilityZone]*liquid.AZResourceCapacityReport, len(req.AllAZs)) - azCapacity := make(map[liquid.AvailabilityZone]*liquid.AZResourceCapacityReport, len(req.AllAZs)) for _, az := range req.AllAZs { crd, ok := crdByKey[groupAZKey{groupName, string(az)}] if !ok { // No CRD for this (group, AZ) pair — report zero. - azCapacity[az] = &liquid.AZResourceCapacityReport{Capacity: 0} + zero := &liquid.AZResourceCapacityReport{Capacity: 0} + ramAZCapacity[az] = zero + coresAZCapacity[az] = &liquid.AZResourceCapacityReport{Capacity: 0} + instancesAZCapacity[az] = &liquid.AZResourceCapacityReport{Capacity: 0} continue } // If the CRD data is stale, report last-known capacity but omit usage. - ready := apimeta.IsStatusConditionTrue(crd.Status.Conditions, v1alpha1.FlavorGroupCapacityConditionReady) - if !ready { + if !apimeta.IsStatusConditionTrue(crd.Status.Conditions, v1alpha1.FlavorGroupCapacityConditionReady) { logger.Info("FlavorGroupCapacity CRD is stale, reporting capacity without usage", "flavorGroup", groupName, "az", az) } @@ -89,50 +108,51 @@ func (c *CapacityCalculator) CalculateCapacity(ctx context.Context, req liquid.S } } if smallest == nil { - azCapacity[az] = &liquid.AZResourceCapacityReport{Capacity: 0} + zero := &liquid.AZResourceCapacityReport{Capacity: 0} + ramAZCapacity[az] = zero + coresAZCapacity[az] = &liquid.AZResourceCapacityReport{Capacity: 0} + instancesAZCapacity[az] = &liquid.AZResourceCapacityReport{Capacity: 0} continue } - capacity := uint64(smallest.TotalCapacityVMSlots) //nolint:gosec - azEntry := &liquid.AZResourceCapacityReport{Capacity: capacity} - if ready { - placeable := uint64(smallest.PlaceableVMs) //nolint:gosec - var usage uint64 - if capacity > placeable { - usage = capacity - placeable + totalSlots := uint64(smallest.TotalCapacityVMSlots) //nolint:gosec // slot count from CRD, realistically bounded + ramEntry := &liquid.AZResourceCapacityReport{Capacity: totalSlots * memoryMBPerSlot / 1024} + coresEntry := &liquid.AZResourceCapacityReport{Capacity: totalSlots * vcpusPerSlot} + instancesEntry := &liquid.AZResourceCapacityReport{Capacity: totalSlots} + + // Usage is approximated from slot counts. This may slightly under-report usage when + // larger flavors are running (safe direction: shows more free capacity than reality). + if apimeta.IsStatusConditionTrue(crd.Status.Conditions, v1alpha1.FlavorGroupCapacityConditionReady) { + placeableSlots := uint64(smallest.PlaceableVMs) //nolint:gosec // slot count from CRD, realistically bounded + var usedSlots uint64 + if totalSlots > placeableSlots { + usedSlots = totalSlots - placeableSlots } - azEntry.Usage = Some[uint64](usage) + ramEntry.Usage = Some[uint64](usedSlots * memoryMBPerSlot / 1024) + coresEntry.Usage = Some[uint64](usedSlots * vcpusPerSlot) + instancesEntry.Usage = Some[uint64](usedSlots) } - azCapacity[az] = azEntry + ramAZCapacity[az] = ramEntry + coresAZCapacity[az] = coresEntry + instancesAZCapacity[az] = instancesEntry } - // All three resources share the same capacity units (multiples of smallest flavor). - report.Resources[liquid.ResourceName(ResourceNameRAM(groupName))] = &liquid.ResourceCapacityReport{ - PerAZ: azCapacity, + if resCfg.RAM.HasCapacity { + report.Resources[liquid.ResourceName(ResourceNameRAM(groupName))] = &liquid.ResourceCapacityReport{ + PerAZ: ramAZCapacity, + } } - report.Resources[liquid.ResourceName(ResourceNameCores(groupName))] = &liquid.ResourceCapacityReport{ - PerAZ: c.copyAZCapacity(azCapacity), + if resCfg.Cores.HasCapacity { + report.Resources[liquid.ResourceName(ResourceNameCores(groupName))] = &liquid.ResourceCapacityReport{ + PerAZ: coresAZCapacity, + } } - report.Resources[liquid.ResourceName(ResourceNameInstances(groupName))] = &liquid.ResourceCapacityReport{ - PerAZ: c.copyAZCapacity(azCapacity), + if resCfg.Instances.HasCapacity { + report.Resources[liquid.ResourceName(ResourceNameInstances(groupName))] = &liquid.ResourceCapacityReport{ + PerAZ: instancesAZCapacity, + } } } return report, nil } - -// copyAZCapacity creates a deep copy of the AZ capacity map. -// Each resource needs its own map instance. -func (c *CapacityCalculator) copyAZCapacity( - src map[liquid.AvailabilityZone]*liquid.AZResourceCapacityReport, -) map[liquid.AvailabilityZone]*liquid.AZResourceCapacityReport { - - result := make(map[liquid.AvailabilityZone]*liquid.AZResourceCapacityReport, len(src)) - for az, report := range src { - result[az] = &liquid.AZResourceCapacityReport{ - Capacity: report.Capacity, - Usage: report.Usage, - } - } - return result -} diff --git a/internal/scheduling/reservations/commitments/field_index.go b/internal/scheduling/reservations/commitments/field_index.go index 5bcbefdc3..1f3733615 100644 --- a/internal/scheduling/reservations/commitments/field_index.go +++ b/internal/scheduling/reservations/commitments/field_index.go @@ -17,6 +17,7 @@ import ( const idxCommittedResourceByUUID = "spec.commitmentUUID" const idxCommittedResourceByProjectID = "spec.projectID" const idxReservationByCommitmentUUID = "spec.committedResourceReservation.commitmentUUID" +const idxProjectQuotaByProjectID = "spec.projectID" // once guards ensure each field index is registered exactly once. // Both CommittedResourceController and UsageReconciler call indexCommittedResourceByUUID; @@ -25,6 +26,7 @@ var ( onceIndexCRByUUID sync.Once onceIndexCRByProjectID sync.Once onceIndexReservationByUUID sync.Once + onceIndexPQByProjectID sync.Once ) // indexCommittedResourceByUUID registers the index used by UsageReconciler to look up @@ -101,3 +103,28 @@ func indexReservationByCommitmentUUID(ctx context.Context, mcl *multicluster.Cli }) return err } + +// indexProjectQuotaByProjectID registers the index used by UsageCalculator to look up +// a project's ProjectQuota CRD by its ProjectID without assuming a naming convention. +func indexProjectQuotaByProjectID(ctx context.Context, mcl *multicluster.Client) (err error) { + onceIndexPQByProjectID.Do(func() { + log := logf.FromContext(ctx) + err = mcl.IndexField(ctx, + &v1alpha1.ProjectQuota{}, + &v1alpha1.ProjectQuotaList{}, + idxProjectQuotaByProjectID, + func(obj client.Object) []string { + pq, ok := obj.(*v1alpha1.ProjectQuota) + if !ok { + log.Error(errors.New("unexpected type"), "expected ProjectQuota", "object", obj) + return nil + } + if pq.Spec.ProjectID == "" { + return nil + } + return []string{pq.Spec.ProjectID} + }, + ) + }) + return err +} diff --git a/internal/scheduling/reservations/commitments/state.go b/internal/scheduling/reservations/commitments/state.go index 149cdfc03..77c5e9b4a 100644 --- a/internal/scheduling/reservations/commitments/state.go +++ b/internal/scheduling/reservations/commitments/state.go @@ -11,7 +11,6 @@ import ( "time" "github.com/cobaltcore-dev/cortex/api/v1alpha1" - "github.com/cobaltcore-dev/cortex/internal/knowledge/extractor/plugins/compute" "github.com/sapcc/go-api-declarations/liquid" ) @@ -109,7 +108,6 @@ type CommitmentState struct { // FromCommitment converts Limes commitment to CommitmentState. func FromCommitment( commitment Commitment, - flavorGroup compute.FlavorGroupFeature, ) (*CommitmentState, error) { // Validate commitment UUID format if !commitmentUUIDPattern.MatchString(commitment.UUID) { @@ -121,9 +119,9 @@ func FromCommitment( return nil, err } - // Calculate total memory from commitment amount (amount = multiples of smallest flavor) - smallestFlavorMemoryBytes := int64(flavorGroup.SmallestFlavor.MemoryMB) * 1024 * 1024 //nolint:gosec // flavor memory from specs, realistically bounded - totalMemoryBytes := int64(commitment.Amount) * smallestFlavorMemoryBytes //nolint:gosec // commitment amount from Limes API, bounded by quota limits + // Calculate total memory from commitment amount (1 GiB per unit) + const gibInBytes = int64(1) << 30 + totalMemoryBytes := int64(commitment.Amount) * gibInBytes //nolint:gosec // commitment amount from Limes API, bounded by quota limits // Set start time: use ConfirmedAt if available, otherwise CreatedAt var startTime *time.Time @@ -161,7 +159,6 @@ func FromChangeCommitmentTargetState( projectID string, domainID string, flavorGroupName string, - flavorGroup compute.FlavorGroupFeature, az string, ) (*CommitmentState, error) { // Validate commitment UUID format @@ -202,12 +199,9 @@ func FromChangeCommitmentTargetState( } } - // Flavors are sorted by size descending, so the last one is the smallest - smallestFlavor := flavorGroup.SmallestFlavor - smallestFlavorMemoryBytes := int64(smallestFlavor.MemoryMB) * 1024 * 1024 //nolint:gosec // flavor memory from specs, realistically bounded - - // Amount represents multiples of the smallest flavor in the group - totalMemoryBytes := int64(amountMultiple) * smallestFlavorMemoryBytes + // Amount represents GiB of RAM (1 GiB per unit) + const gibInBytes = int64(1) << 30 + totalMemoryBytes := int64(amountMultiple) * gibInBytes return &CommitmentState{ CommitmentUUID: string(commitment.UUID), diff --git a/internal/scheduling/reservations/commitments/state_test.go b/internal/scheduling/reservations/commitments/state_test.go index 3aba27b71..40026ee43 100644 --- a/internal/scheduling/reservations/commitments/state_test.go +++ b/internal/scheduling/reservations/commitments/state_test.go @@ -32,7 +32,6 @@ func testFlavorGroup() compute.FlavorGroupFeature { } func TestFromCommitment_CalculatesMemoryCorrectly(t *testing.T) { - flavorGroup := testFlavorGroup() commitment := Commitment{ UUID: "test-uuid", ProjectID: "project-1", @@ -40,7 +39,7 @@ func TestFromCommitment_CalculatesMemoryCorrectly(t *testing.T) { Amount: 5, // 5 multiples of smallest flavor } - state, err := FromCommitment(commitment, flavorGroup) + state, err := FromCommitment(commitment) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -56,15 +55,14 @@ func TestFromCommitment_CalculatesMemoryCorrectly(t *testing.T) { t.Errorf("expected FlavorGroupName test-group, got %s", state.FlavorGroupName) } - // Verify memory calculation: 5 * 8192 MB = 40960 MB = 42949672960 bytes - expectedMemory := int64(5 * 8192 * 1024 * 1024) + // Verify memory calculation: 5 GiB = 5 * 1<<30 bytes + expectedMemory := int64(5) * (1 << 30) if state.TotalMemoryBytes != expectedMemory { t.Errorf("expected memory %d, got %d", expectedMemory, state.TotalMemoryBytes) } } func TestFromCommitment_InvalidResourceName(t *testing.T) { - flavorGroup := testFlavorGroup() commitment := Commitment{ UUID: "test-uuid", ProjectID: "project-1", @@ -72,7 +70,7 @@ func TestFromCommitment_InvalidResourceName(t *testing.T) { Amount: 1, } - _, err := FromCommitment(commitment, flavorGroup) + _, err := FromCommitment(commitment) if err == nil { t.Fatal("expected error for invalid resource name, got nil") } diff --git a/internal/scheduling/reservations/commitments/syncer.go b/internal/scheduling/reservations/commitments/syncer.go index 7c96823d0..939d1cfc2 100644 --- a/internal/scheduling/reservations/commitments/syncer.go +++ b/internal/scheduling/reservations/commitments/syncer.go @@ -13,6 +13,7 @@ import ( "github.com/cobaltcore-dev/cortex/internal/knowledge/extractor/plugins/compute" "github.com/cobaltcore-dev/cortex/internal/scheduling/reservations" "github.com/go-logr/logr" + "github.com/sapcc/go-api-declarations/liquid" corev1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/api/resource" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -140,9 +141,8 @@ func (s *Syncer) getCommitmentStates(ctx context.Context, log logr.Logger, flavo continue } - // Validate unit matches between Limes commitment and Cortex flavor group - // Expected format: " MiB" e.g. "131072 MiB" for 128 GiB - expectedUnit := fmt.Sprintf("%d MiB", flavorGroup.SmallestFlavor.MemoryMB) + // Validate unit matches between Limes commitment and Cortex (1 GiB per unit) + expectedUnit := liquid.UnitGibibytes.String() // "GiB" if commitment.Unit != "" && commitment.Unit != expectedUnit { // Unit mismatch: Limes has not yet updated this commitment to the new unit. // Skip this commitment - trust what Cortex already has stored in CRDs. @@ -174,7 +174,7 @@ func (s *Syncer) getCommitmentStates(ctx context.Context, log logr.Logger, flavo } // Convert commitment to state using FromCommitment - state, err := FromCommitment(commitment, flavorGroup) + state, err := FromCommitment(commitment) if err != nil { log.Error(err, "failed to convert commitment to state", "id", id, diff --git a/internal/scheduling/reservations/commitments/syncer_test.go b/internal/scheduling/reservations/commitments/syncer_test.go index 28a464d1e..27e2ae6c1 100644 --- a/internal/scheduling/reservations/commitments/syncer_test.go +++ b/internal/scheduling/reservations/commitments/syncer_test.go @@ -412,8 +412,8 @@ func TestSyncer_SyncReservations_UnitMismatch(t *testing.T) { WithObjects(flavorGroupsKnowledge). Build() - // Create mock commitment with a unit that doesn't match Cortex's understanding - // Limes says "2048 MiB" but Cortex's smallest flavor is 1024 MB + // Create mock commitment with a unit that doesn't match Cortex's expected "GiB" + // Limes says "2048 MiB" but Cortex expects "GiB" mockCommitments := []Commitment{ { ID: 1, @@ -422,7 +422,7 @@ func TestSyncer_SyncReservations_UnitMismatch(t *testing.T) { ResourceName: "hw_version_test_group_v1_ram", AvailabilityZone: "az1", Amount: 2, - Unit: "2048 MiB", // Mismatched unit - should be "1024 MiB" + Unit: "2048 MiB", // Mismatched unit - should be "GiB" Status: "confirmed", ProjectID: "test-project", DomainID: "test-domain", @@ -502,7 +502,7 @@ func TestSyncer_SyncReservations_UnitMatch(t *testing.T) { ResourceName: "hw_version_test_group_v1_ram", AvailabilityZone: "az1", Amount: 2, - Unit: "1024 MiB", // Correct unit matching smallest flavor + Unit: "GiB", // Correct unit matching Cortex's expected GiB Status: "confirmed", ProjectID: "test-project", DomainID: "test-domain", diff --git a/internal/scheduling/reservations/commitments/usage.go b/internal/scheduling/reservations/commitments/usage.go index a9333fdaa..f3d795ff9 100644 --- a/internal/scheduling/reservations/commitments/usage.go +++ b/internal/scheduling/reservations/commitments/usage.go @@ -100,7 +100,7 @@ type VMUsageInfo struct { AZ string Hypervisor string CreatedAt time.Time - UsageMultiple uint64 // Memory in multiples of smallest flavor in the group + UsageMultiple uint64 // RAM in GiB } // UsageCalculator computes usage reports for Limes LIQUID API. @@ -144,12 +144,20 @@ func (c *UsageCalculator) CalculateUsage( return liquid.ServiceUsageReport{}, fmt.Errorf("failed to read VM assignments from CRD status: %w", err) } + // Fetch the ProjectQuota CRD for this project to read per-AZ quota values. + // May not exist if Limes has not pushed quota yet — in that case quota defaults to infinite. + var projectQuota *v1alpha1.ProjectQuota + var pqList v1alpha1.ProjectQuotaList + if err := c.client.List(ctx, &pqList, client.MatchingFields{idxProjectQuotaByProjectID: projectID}); err == nil && len(pqList.Items) > 0 { + projectQuota = &pqList.Items[0] + } + vms, err := getProjectVMs(ctx, c.usageDB, log, projectID, flavorGroups, allAZs) if err != nil { return liquid.ServiceUsageReport{}, fmt.Errorf("failed to get project VMs: %w", err) } - report := c.buildUsageResponse(vms, vmAssignments, flavorGroups, allAZs, infoVersion) + report := c.buildUsageResponse(vms, vmAssignments, flavorGroups, allAZs, infoVersion, projectQuota) assignedToCommitments := 0 for _, vm := range vms { @@ -276,17 +284,10 @@ func getProjectVMs( // Build flavor name -> flavor group lookup flavorToGroup := make(map[string]string) - flavorToSmallestMemory := make(map[string]uint64) // for calculating usage multiples for groupName, group := range flavorGroups { for _, flavor := range group.Flavors { flavorToGroup[flavor.Name] = groupName } - // Smallest flavor in group determines the usage unit - if group.SmallestFlavor.Name != "" { - for _, flavor := range group.Flavors { - flavorToSmallestMemory[flavor.Name] = group.SmallestFlavor.MemoryMB - } - } } var vms []VMUsageInfo @@ -302,10 +303,12 @@ func getProjectVMs( // Determine flavor group flavorGroup := flavorToGroup[row.FlavorName] - // Calculate usage multiple (memory in units of smallest flavor) + // Calculate usage in GiB (FlavorRAM is in MiB). + // Add 16 MiB before dividing: flavors reserve 16 MiB for video RAM (hw_video:ram_max_mb=16), + // so a nominal "2 GiB" flavor has 2032 MiB. Without the adjustment, integer division truncates. var usageMultiple uint64 - if smallestMem := flavorToSmallestMemory[row.FlavorName]; smallestMem > 0 { - usageMultiple = row.FlavorRAM / smallestMem + if row.FlavorRAM > 0 { + usageMultiple = (row.FlavorRAM + 16) / 1024 } // Normalize AZ @@ -431,6 +434,7 @@ func (c *UsageCalculator) buildUsageResponse( flavorGroups map[string]compute.FlavorGroupFeature, allAZs []liquid.AvailabilityZone, infoVersion int64, + projectQuota *v1alpha1.ProjectQuota, ) liquid.ServiceUsageReport { // Initialize resources map for all flavor groups resources := make(map[liquid.ResourceName]*liquid.ResourceUsageReport) @@ -485,7 +489,6 @@ func (c *UsageCalculator) buildUsageResponse( ramResourceName := liquid.ResourceName(ResourceNameRAM(flavorGroupName)) ramPerAZ := make(map[liquid.AvailabilityZone]*liquid.AZResourceUsageReport) // For AZSeparatedTopology resources (fixed-ratio groups), per-AZ Quota must be non-null. - // Use -1 ("infinite quota") as default until actual quota is read from ProjectQuota CRD. ramHasAZQuota := groupData.HasFixedRamCoreRatio() for _, az := range allAZs { report := &liquid.AZResourceUsageReport{ @@ -493,21 +496,24 @@ func (c *UsageCalculator) buildUsageResponse( Subresources: []liquid.Subresource{}, } if ramHasAZQuota { - report.Quota = Some(int64(-1)) // infinite — will be overridden by ProjectQuota CRD + quota := int64(-1) // default: infinite + if projectQuota != nil { + if rq, ok := projectQuota.Spec.Quota[string(ramResourceName)]; ok { + if q, ok := rq.PerAZ[string(az)]; ok { + quota = q + } + } + } + report.Quota = Some(quota) } ramPerAZ[az] = report } if azData, exists := usageByFlavorGroupAZ[flavorGroupName]; exists { for az, data := range azData { if _, known := ramPerAZ[az]; !known { - report := &liquid.AZResourceUsageReport{} - if ramHasAZQuota { - report.Quota = Some(int64(-1)) - } - ramPerAZ[az] = report + continue // skip VMs in AZs not in allAZs } ramPerAZ[az].Usage = data.ramUsage - ramPerAZ[az].PhysicalUsage = Some(data.ramUsage) // No overcommit for RAM // Subresources are only on instances resource } } @@ -527,10 +533,9 @@ func (c *UsageCalculator) buildUsageResponse( if azData, exists := usageByFlavorGroupAZ[flavorGroupName]; exists { for az, data := range azData { if _, known := coresPerAZ[az]; !known { - coresPerAZ[az] = &liquid.AZResourceUsageReport{} + continue // skip VMs in AZs not in allAZs } coresPerAZ[az].Usage = data.coresUsage - coresPerAZ[az].PhysicalUsage = Some(data.coresUsage) // No overcommit for cores // Subresources are only on instances resource } } @@ -550,10 +555,9 @@ func (c *UsageCalculator) buildUsageResponse( if azData, exists := usageByFlavorGroupAZ[flavorGroupName]; exists { for az, data := range azData { if _, known := instancesPerAZ[az]; !known { - instancesPerAZ[az] = &liquid.AZResourceUsageReport{} + continue // skip VMs in AZs not in allAZs } instancesPerAZ[az].Usage = data.instanceCount - instancesPerAZ[az].PhysicalUsage = Some(data.instanceCount) instancesPerAZ[az].Subresources = data.subresources // VM details on instances resource } } diff --git a/internal/scheduling/reservations/commitments/usage_reconciler.go b/internal/scheduling/reservations/commitments/usage_reconciler.go index 4d09439b9..6314c53c8 100644 --- a/internal/scheduling/reservations/commitments/usage_reconciler.go +++ b/internal/scheduling/reservations/commitments/usage_reconciler.go @@ -278,6 +278,9 @@ func (r *UsageReconciler) SetupWithManager(mgr ctrl.Manager, mcl *multicluster.C if err := indexCommittedResourceByProjectID(context.Background(), mcl); err != nil { return fmt.Errorf("failed to set up committed resource project index: %w", err) } + if err := indexProjectQuotaByProjectID(context.Background(), mcl); err != nil { + return fmt.Errorf("failed to set up project quota project index: %w", err) + } bldr := multicluster.BuildController(mcl, mgr) diff --git a/internal/scheduling/reservations/quota/controller.go b/internal/scheduling/reservations/quota/controller.go index f00d040dc..5a4d49e74 100644 --- a/internal/scheduling/reservations/quota/controller.go +++ b/internal/scheduling/reservations/quota/controller.go @@ -418,17 +418,11 @@ func (c *QuotaController) accumulateAddedVM( if !ok { return // Flavor not in any group } - fg, ok := flavorGroups[groupName] - if !ok { + if _, ok := flavorGroups[groupName]; !ok { return } - unitSizeMiB := int64(fg.SmallestFlavor.MemoryMB) //nolint:gosec // MemoryMB is always within int64 range - if unitSizeMiB == 0 { - return - } - - ramUnits, coresAmount := vmResourceUnits(vm.Resources, unitSizeMiB) + ramUnits, coresAmount := vmResourceUnits(vm.Resources) delta := projectDeltas[vm.ProjectID] if delta == nil { @@ -523,19 +517,13 @@ func (c *QuotaController) accumulateRemovedVM( if !ok { return // Flavor not in any group } - fg, ok := flavorGroups[groupName] - if !ok { + if _, ok := flavorGroups[groupName]; !ok { return } // Compute commitment units from the resolved flavor resources - unitSizeMiB := int64(fg.SmallestFlavor.MemoryMB) //nolint:gosec // MemoryMB is always within int64 range - if unitSizeMiB == 0 { - return - } - - ramUnits := int64(info.RAMMiB) / unitSizeMiB //nolint:gosec // safe - coresAmount := int64(info.VCPUs) //nolint:gosec // safe + ramUnits := int64(info.RAMMiB) / 1024 //nolint:gosec // safe + coresAmount := int64(info.VCPUs) //nolint:gosec // safe delta := projectDeltas[info.ProjectID] if delta == nil { @@ -700,19 +688,14 @@ func (c *QuotaController) computeTotalUsage( if !ok { continue // Flavor not in any tracked group } - fg, ok := flavorGroups[groupName] - if !ok { + if _, ok := flavorGroups[groupName]; !ok { continue } - if fg.SmallestFlavor.MemoryMB == 0 { - continue // Invalid group config - } ramResourceName := commitments.ResourceNameRAM(groupName) coresResourceName := commitments.ResourceNameCores(groupName) - unitSizeMiB := int64(fg.SmallestFlavor.MemoryMB) //nolint:gosec // safe - ramUnits, coresAmount := vmResourceUnits(vm.Resources, unitSizeMiB) + ramUnits, coresAmount := vmResourceUnits(vm.Resources) if _, ok := result[vm.ProjectID]; !ok { result[vm.ProjectID] = make(map[string]v1alpha1.ResourceQuotaUsage) @@ -783,14 +766,12 @@ func (c *QuotaController) computeCRUsage(crs []v1alpha1.CommittedResource, flavo if !ok { continue } - // Convert bytes to commitment units (multiples of smallest flavor) + // Convert bytes to GiB (1 GiB per commitment unit) usedBytes := memQty.Value() - fg, ok := flavorGroups[spec.FlavorGroupName] - if !ok || fg.SmallestFlavor.MemoryMB == 0 { + if _, ok := flavorGroups[spec.FlavorGroupName]; !ok { continue } - unitSizeBytes := int64(fg.SmallestFlavor.MemoryMB) * 1024 * 1024 //nolint:gosec // safe - usedAmount = usedBytes / unitSizeBytes + usedAmount = usedBytes / (1024 * 1024 * 1024) case v1alpha1.CommittedResourceTypeCores: resourceName = commitments.ResourceNameCores(spec.FlavorGroupName) cpuQty, ok := cr.Status.UsedResources["cpu"] @@ -889,16 +870,14 @@ func (c *QuotaController) updateProjectQuotaStatusWithRetry( }) } -// vmResourceUnits computes RAM commitment units and cores from a VM's resources. -// RAM is converted from bytes (resource.Quantity) to MiB, then divided by unitSizeMiB -// (the smallest flavor's memory in MiB for the flavor group) to get commitment units. -func vmResourceUnits(resources map[string]resource.Quantity, unitSizeMiB int64) (ramUnits, cores int64) { +// vmResourceUnits computes RAM commitment units (GiB) and cores from a VM's resources. +func vmResourceUnits(resources map[string]resource.Quantity) (ramGiB, cores int64) { memQty := resources["memory"] serverRAMMiB := memQty.Value() / (1024 * 1024) // bytes to MiB - ramUnits = serverRAMMiB / unitSizeMiB // commitment units + ramGiB = serverRAMMiB / 1024 // MiB to GiB (1 GiB per unit) vcpuQty := resources["vcpus"] cores = vcpuQty.Value() - return ramUnits, cores + return ramGiB, cores } // buildFlavorToGroupMap builds a flavorName → flavorGroupName lookup from flavor groups. diff --git a/internal/scheduling/reservations/quota/controller_test.go b/internal/scheduling/reservations/quota/controller_test.go index d503b363f..df995876e 100644 --- a/internal/scheduling/reservations/quota/controller_test.go +++ b/internal/scheduling/reservations/quota/controller_test.go @@ -93,19 +93,19 @@ func TestComputeTotalUsage(t *testing.T) { result := ctrl.computeTotalUsage(vms, flavorToGroup, flavorGroups) - // project-a: hana_v2 in az-1: 32768+65536 = 98304 MiB / 32768 = 3 units RAM, 8+16=24 cores - // project-a: hana_v2 in az-2: 32768 MiB / 32768 = 1 unit RAM, 8 cores + // project-a: hana_v2 in az-1: (32768+65536)/1024 = 96 GiB RAM, 8+16=24 cores + // project-a: hana_v2 in az-2: 32768/1024 = 32 GiB RAM, 8 cores projectA := result["project-a"] if projectA == nil { t.Fatal("expected project-a in results") } ramUsage := projectA["hw_version_hana_v2_ram"] - if ramUsage.PerAZ["az-1"] != 3 { - t.Errorf("expected project-a az-1 hana_v2_ram = 3, got %d", ramUsage.PerAZ["az-1"]) + if ramUsage.PerAZ["az-1"] != 96 { + t.Errorf("expected project-a az-1 hana_v2_ram = 96, got %d", ramUsage.PerAZ["az-1"]) } - if ramUsage.PerAZ["az-2"] != 1 { - t.Errorf("expected project-a az-2 hana_v2_ram = 1, got %d", ramUsage.PerAZ["az-2"]) + if ramUsage.PerAZ["az-2"] != 32 { + t.Errorf("expected project-a az-2 hana_v2_ram = 32, got %d", ramUsage.PerAZ["az-2"]) } coresUsage := projectA["hw_version_hana_v2_cores"] @@ -116,13 +116,13 @@ func TestComputeTotalUsage(t *testing.T) { t.Errorf("expected project-a az-2 hana_v2_cores = 8, got %d", coresUsage.PerAZ["az-2"]) } - // project-b: general in az-1: 4096/4096=1 unit RAM, 2 cores + // project-b: general in az-1: 4096/1024 = 4 GiB RAM, 2 cores projectB := result["project-b"] if projectB == nil { t.Fatal("expected project-b in results") } - if projectB["hw_version_general_ram"].PerAZ["az-1"] != 1 { - t.Errorf("expected project-b az-1 general_ram = 1, got %d", projectB["hw_version_general_ram"].PerAZ["az-1"]) + if projectB["hw_version_general_ram"].PerAZ["az-1"] != 4 { + t.Errorf("expected project-b az-1 general_ram = 4, got %d", projectB["hw_version_general_ram"].PerAZ["az-1"]) } if projectB["hw_version_general_cores"].PerAZ["az-1"] != 2 { t.Errorf("expected project-b az-1 general_cores = 2, got %d", projectB["hw_version_general_cores"].PerAZ["az-1"]) @@ -137,12 +137,11 @@ func TestComputeTotalUsage(t *testing.T) { func TestComputeCRUsage(t *testing.T) { ctrl := &QuotaController{Config: DefaultQuotaControllerConfig()} - // Flavor groups with SmallestFlavor.MemoryMB = 1 for simple unit conversion in tests - // (1 multiple = 1 MiB = 1048576 bytes) + // Flavor groups — MemoryMB value is no longer used for unit conversion (now fixed at 1 GiB). testFlavorGroups := map[string]compute.FlavorGroupFeature{ "hana_v2": { - SmallestFlavor: compute.FlavorInGroup{Name: "m1.hana_v2.small", MemoryMB: 1}, - Flavors: []compute.FlavorInGroup{{Name: "m1.hana_v2.small", MemoryMB: 1}}, + SmallestFlavor: compute.FlavorInGroup{Name: "m1.hana_v2.small", MemoryMB: 1024}, + Flavors: []compute.FlavorInGroup{{Name: "m1.hana_v2.small", MemoryMB: 1024}}, }, } @@ -156,7 +155,7 @@ func TestComputeCRUsage(t *testing.T) { State: v1alpha1.CommitmentStatusConfirmed, }, Status: v1alpha1.CommittedResourceStatus{ - UsedResources: map[string]resource.Quantity{"memory": resource.MustParse("5Mi")}, + UsedResources: map[string]resource.Quantity{"memory": resource.MustParse("5Gi")}, }, }, { @@ -168,7 +167,7 @@ func TestComputeCRUsage(t *testing.T) { State: v1alpha1.CommitmentStatusGuaranteed, }, Status: v1alpha1.CommittedResourceStatus{ - UsedResources: map[string]resource.Quantity{"memory": resource.MustParse("3Mi")}, + UsedResources: map[string]resource.Quantity{"memory": resource.MustParse("3Gi")}, }, }, { @@ -193,7 +192,7 @@ func TestComputeCRUsage(t *testing.T) { State: v1alpha1.CommitmentStatusConfirmed, }, Status: v1alpha1.CommittedResourceStatus{ - UsedResources: map[string]resource.Quantity{"memory": resource.MustParse("5Mi")}, + UsedResources: map[string]resource.Quantity{"memory": resource.MustParse("5Gi")}, }, }, // Pending state — should be excluded by state filter @@ -206,7 +205,7 @@ func TestComputeCRUsage(t *testing.T) { State: v1alpha1.CommitmentStatusPending, }, Status: v1alpha1.CommittedResourceStatus{ - UsedResources: map[string]resource.Quantity{"memory": resource.MustParse("2Mi")}, + UsedResources: map[string]resource.Quantity{"memory": resource.MustParse("2Gi")}, }, }, } @@ -554,9 +553,9 @@ func TestAccumulateAddedVM_KnownFlavor(t *testing.T) { t.Fatal("expected delta for project-a") } - // 32768 MiB / 32768 = 1 unit RAM - if delta.increments["hw_version_hana_v2_ram"]["az-1"] != 1 { - t.Errorf("expected ram increment = 1, got %d", delta.increments["hw_version_hana_v2_ram"]["az-1"]) + // 32768 MiB / 1024 = 32 GiB + if delta.increments["hw_version_hana_v2_ram"]["az-1"] != 32 { + t.Errorf("expected ram increment = 32, got %d", delta.increments["hw_version_hana_v2_ram"]["az-1"]) } if delta.increments["hw_version_hana_v2_cores"]["az-1"] != 8 { t.Errorf("expected cores increment = 8, got %d", delta.increments["hw_version_hana_v2_cores"]["az-1"]) diff --git a/internal/scheduling/reservations/quota/integration_test.go b/internal/scheduling/reservations/quota/integration_test.go index 740341d9a..3ec320ca3 100644 --- a/internal/scheduling/reservations/quota/integration_test.go +++ b/internal/scheduling/reservations/quota/integration_test.go @@ -40,32 +40,32 @@ func TestIntegration(t *testing.T) { Actions: []TestAction{ { Type: "full_reconcile", - // project-a: hana_v2 az-1: (32768+65536)/32768 = 3 RAM units, 8+16=24 cores - // project-a: hana_v2 az-2: 32768/32768 = 1 RAM unit, 8 cores - // project-a: general az-1: 4096/4096 = 1 RAM unit, 2 cores - // project-b: general az-1: 4096/4096 = 1 RAM unit, 2 cores + // project-a: hana_v2 az-1: (32768+65536)/1024 = 96 GiB, 8+16=24 cores + // project-a: hana_v2 az-2: 32768/1024 = 32 GiB, 8 cores + // project-a: general az-1: 4096/1024 = 4 GiB, 2 cores + // project-b: general az-1: 4096/1024 = 4 GiB, 2 cores ExpectedTotalUsage: map[string]map[string]v1alpha1.ResourceQuotaUsage{ "project-a": { - "hw_version_hana_v2_ram": {PerAZ: map[string]int64{"az-1": 3, "az-2": 1}}, + "hw_version_hana_v2_ram": {PerAZ: map[string]int64{"az-1": 96, "az-2": 32}}, "hw_version_hana_v2_cores": {PerAZ: map[string]int64{"az-1": 24, "az-2": 8}}, - "hw_version_general_ram": {PerAZ: map[string]int64{"az-1": 1}}, + "hw_version_general_ram": {PerAZ: map[string]int64{"az-1": 4}}, "hw_version_general_cores": {PerAZ: map[string]int64{"az-1": 2}}, }, "project-b": { - "hw_version_general_ram": {PerAZ: map[string]int64{"az-1": 1}}, + "hw_version_general_ram": {PerAZ: map[string]int64{"az-1": 4}}, "hw_version_general_cores": {PerAZ: map[string]int64{"az-1": 2}}, }, }, // No CRs -> PaygUsage == TotalUsage ExpectedPaygUsage: map[string]map[string]v1alpha1.ResourceQuotaUsage{ "project-a": { - "hw_version_hana_v2_ram": {PerAZ: map[string]int64{"az-1": 3, "az-2": 1}}, + "hw_version_hana_v2_ram": {PerAZ: map[string]int64{"az-1": 96, "az-2": 32}}, "hw_version_hana_v2_cores": {PerAZ: map[string]int64{"az-1": 24, "az-2": 8}}, - "hw_version_general_ram": {PerAZ: map[string]int64{"az-1": 1}}, + "hw_version_general_ram": {PerAZ: map[string]int64{"az-1": 4}}, "hw_version_general_cores": {PerAZ: map[string]int64{"az-1": 2}}, }, "project-b": { - "hw_version_general_ram": {PerAZ: map[string]int64{"az-1": 1}}, + "hw_version_general_ram": {PerAZ: map[string]int64{"az-1": 4}}, "hw_version_general_cores": {PerAZ: map[string]int64{"az-1": 2}}, }, }, @@ -92,21 +92,21 @@ func TestIntegration(t *testing.T) { Type: "full_reconcile", ExpectedTotalUsage: map[string]map[string]v1alpha1.ResourceQuotaUsage{ "project-a": { - "hw_version_hana_v2_ram": {PerAZ: map[string]int64{"az-1": 3, "az-2": 1}}, + "hw_version_hana_v2_ram": {PerAZ: map[string]int64{"az-1": 96, "az-2": 32}}, "hw_version_hana_v2_cores": {PerAZ: map[string]int64{"az-1": 24, "az-2": 8}}, - "hw_version_general_ram": {PerAZ: map[string]int64{"az-1": 1}}, + "hw_version_general_ram": {PerAZ: map[string]int64{"az-1": 4}}, "hw_version_general_cores": {PerAZ: map[string]int64{"az-1": 2}}, }, }, // PaygUsage = TotalUsage - CRUsage - // hana_v2 RAM: 3-2=1 in az-1, 1-0=1 in az-2 + // hana_v2 RAM: 96-2=94 in az-1, 32-0=32 in az-2 // hana_v2 Cores: 24-10=14 in az-1, 8-0=8 in az-2 // general: no CRs so PaygUsage == TotalUsage ExpectedPaygUsage: map[string]map[string]v1alpha1.ResourceQuotaUsage{ "project-a": { - "hw_version_hana_v2_ram": {PerAZ: map[string]int64{"az-1": 1, "az-2": 1}}, + "hw_version_hana_v2_ram": {PerAZ: map[string]int64{"az-1": 94, "az-2": 32}}, "hw_version_hana_v2_cores": {PerAZ: map[string]int64{"az-1": 14, "az-2": 8}}, - "hw_version_general_ram": {PerAZ: map[string]int64{"az-1": 1}}, + "hw_version_general_ram": {PerAZ: map[string]int64{"az-1": 4}}, "hw_version_general_cores": {PerAZ: map[string]int64{"az-1": 2}}, }, }, @@ -126,9 +126,9 @@ func TestIntegration(t *testing.T) { Type: "full_reconcile", ExpectedTotalUsage: map[string]map[string]v1alpha1.ResourceQuotaUsage{ "project-a": { - "hw_version_hana_v2_ram": {PerAZ: map[string]int64{"az-1": 3, "az-2": 1}}, + "hw_version_hana_v2_ram": {PerAZ: map[string]int64{"az-1": 96, "az-2": 32}}, "hw_version_hana_v2_cores": {PerAZ: map[string]int64{"az-1": 24, "az-2": 8}}, - "hw_version_general_ram": {PerAZ: map[string]int64{"az-1": 1}}, + "hw_version_general_ram": {PerAZ: map[string]int64{"az-1": 4}}, "hw_version_general_cores": {PerAZ: map[string]int64{"az-1": 2}}, }, }, @@ -151,18 +151,18 @@ func TestIntegration(t *testing.T) { ProjectID: "project-a", AvailabilityZone: "az-1", CreatedAt: "2099-01-01T00:00:00Z", // far future, always AFTER last reconcile Resources: map[string]resource.Quantity{ - "memory": resource.MustParse("34359738368"), // 32768 MiB = 1 RAM unit + "memory": resource.MustParse("34359738368"), // 32768 MiB = 32 GiB "vcpus": resource.MustParse("8"), }, }, ), // vm-new is created AFTER last reconcile, so it gets incremented - // +1 RAM unit (32768/32768), +8 cores + // +32 GiB RAM (32768/1024), +8 cores ExpectedTotalUsage: map[string]map[string]v1alpha1.ResourceQuotaUsage{ "project-a": { - "hw_version_hana_v2_ram": {PerAZ: map[string]int64{"az-1": 4, "az-2": 1}}, + "hw_version_hana_v2_ram": {PerAZ: map[string]int64{"az-1": 128, "az-2": 32}}, "hw_version_hana_v2_cores": {PerAZ: map[string]int64{"az-1": 32, "az-2": 8}}, - "hw_version_general_ram": {PerAZ: map[string]int64{"az-1": 1}}, + "hw_version_general_ram": {PerAZ: map[string]int64{"az-1": 4}}, "hw_version_general_cores": {PerAZ: map[string]int64{"az-1": 2}}, }, }, @@ -182,9 +182,9 @@ func TestIntegration(t *testing.T) { Type: "full_reconcile", ExpectedTotalUsage: map[string]map[string]v1alpha1.ResourceQuotaUsage{ "project-a": { - "hw_version_hana_v2_ram": {PerAZ: map[string]int64{"az-1": 3, "az-2": 1}}, + "hw_version_hana_v2_ram": {PerAZ: map[string]int64{"az-1": 96, "az-2": 32}}, "hw_version_hana_v2_cores": {PerAZ: map[string]int64{"az-1": 24, "az-2": 8}}, - "hw_version_general_ram": {PerAZ: map[string]int64{"az-1": 1}}, + "hw_version_general_ram": {PerAZ: map[string]int64{"az-1": 4}}, "hw_version_general_cores": {PerAZ: map[string]int64{"az-1": 2}}, }, }, @@ -199,9 +199,9 @@ func TestIntegration(t *testing.T) { // Should NOT increment -- vm-1 CreatedAt is 2025-12-01 which is before reconcile time ExpectedTotalUsage: map[string]map[string]v1alpha1.ResourceQuotaUsage{ "project-a": { - "hw_version_hana_v2_ram": {PerAZ: map[string]int64{"az-1": 3, "az-2": 1}}, + "hw_version_hana_v2_ram": {PerAZ: map[string]int64{"az-1": 96, "az-2": 32}}, "hw_version_hana_v2_cores": {PerAZ: map[string]int64{"az-1": 24, "az-2": 8}}, - "hw_version_general_ram": {PerAZ: map[string]int64{"az-1": 1}}, + "hw_version_general_ram": {PerAZ: map[string]int64{"az-1": 4}}, "hw_version_general_cores": {PerAZ: map[string]int64{"az-1": 2}}, }, }, @@ -234,9 +234,9 @@ func TestIntegration(t *testing.T) { Type: "full_reconcile", ExpectedTotalUsage: map[string]map[string]v1alpha1.ResourceQuotaUsage{ "project-a": { - "hw_version_hana_v2_ram": {PerAZ: map[string]int64{"az-1": 3, "az-2": 1}}, + "hw_version_hana_v2_ram": {PerAZ: map[string]int64{"az-1": 96, "az-2": 32}}, "hw_version_hana_v2_cores": {PerAZ: map[string]int64{"az-1": 24, "az-2": 8}}, - "hw_version_general_ram": {PerAZ: map[string]int64{"az-1": 1}}, + "hw_version_general_ram": {PerAZ: map[string]int64{"az-1": 4}}, "hw_version_general_cores": {PerAZ: map[string]int64{"az-1": 2}}, }, }, @@ -255,12 +255,12 @@ func TestIntegration(t *testing.T) { // vm-del gone }), // vm-del: IsServerActive=false, deleted info found - // Decrement: -1 RAM unit, -8 cores in az-1 + // Decrement: -32 GiB RAM, -8 cores in az-1 ExpectedTotalUsage: map[string]map[string]v1alpha1.ResourceQuotaUsage{ "project-a": { - "hw_version_hana_v2_ram": {PerAZ: map[string]int64{"az-1": 2, "az-2": 1}}, + "hw_version_hana_v2_ram": {PerAZ: map[string]int64{"az-1": 64, "az-2": 32}}, "hw_version_hana_v2_cores": {PerAZ: map[string]int64{"az-1": 16, "az-2": 8}}, - "hw_version_general_ram": {PerAZ: map[string]int64{"az-1": 1}}, + "hw_version_general_ram": {PerAZ: map[string]int64{"az-1": 4}}, "hw_version_general_cores": {PerAZ: map[string]int64{"az-1": 2}}, }, }, @@ -282,9 +282,9 @@ func TestIntegration(t *testing.T) { Type: "full_reconcile", ExpectedTotalUsage: map[string]map[string]v1alpha1.ResourceQuotaUsage{ "project-a": { - "hw_version_hana_v2_ram": {PerAZ: map[string]int64{"az-1": 3, "az-2": 1}}, + "hw_version_hana_v2_ram": {PerAZ: map[string]int64{"az-1": 96, "az-2": 32}}, "hw_version_hana_v2_cores": {PerAZ: map[string]int64{"az-1": 24, "az-2": 8}}, - "hw_version_general_ram": {PerAZ: map[string]int64{"az-1": 1}}, + "hw_version_general_ram": {PerAZ: map[string]int64{"az-1": 4}}, "hw_version_general_cores": {PerAZ: map[string]int64{"az-1": 2}}, }, }, @@ -303,9 +303,9 @@ func TestIntegration(t *testing.T) { // vm-1: IsServerActive=true, so NOT decremented ExpectedTotalUsage: map[string]map[string]v1alpha1.ResourceQuotaUsage{ "project-a": { - "hw_version_hana_v2_ram": {PerAZ: map[string]int64{"az-1": 3, "az-2": 1}}, + "hw_version_hana_v2_ram": {PerAZ: map[string]int64{"az-1": 96, "az-2": 32}}, "hw_version_hana_v2_cores": {PerAZ: map[string]int64{"az-1": 24, "az-2": 8}}, - "hw_version_general_ram": {PerAZ: map[string]int64{"az-1": 1}}, + "hw_version_general_ram": {PerAZ: map[string]int64{"az-1": 4}}, "hw_version_general_cores": {PerAZ: map[string]int64{"az-1": 2}}, }, }, @@ -329,9 +329,9 @@ func TestIntegration(t *testing.T) { Type: "full_reconcile", ExpectedPaygUsage: map[string]map[string]v1alpha1.ResourceQuotaUsage{ "project-a": { - "hw_version_hana_v2_ram": {PerAZ: map[string]int64{"az-1": 2, "az-2": 1}}, // 3-1=2 + "hw_version_hana_v2_ram": {PerAZ: map[string]int64{"az-1": 95, "az-2": 32}}, // 96-1=95 "hw_version_hana_v2_cores": {PerAZ: map[string]int64{"az-1": 24, "az-2": 8}}, - "hw_version_general_ram": {PerAZ: map[string]int64{"az-1": 1}}, + "hw_version_general_ram": {PerAZ: map[string]int64{"az-1": 4}}, "hw_version_general_cores": {PerAZ: map[string]int64{"az-1": 2}}, }, }, @@ -343,9 +343,9 @@ func TestIntegration(t *testing.T) { UsedAmount: 3, ExpectedPaygUsage: map[string]map[string]v1alpha1.ResourceQuotaUsage{ "project-a": { - "hw_version_hana_v2_ram": {PerAZ: map[string]int64{"az-1": 0, "az-2": 1}}, // 3-3=0 + "hw_version_hana_v2_ram": {PerAZ: map[string]int64{"az-1": 93, "az-2": 32}}, // 96-3=93 "hw_version_hana_v2_cores": {PerAZ: map[string]int64{"az-1": 24, "az-2": 8}}, - "hw_version_general_ram": {PerAZ: map[string]int64{"az-1": 1}}, + "hw_version_general_ram": {PerAZ: map[string]int64{"az-1": 4}}, "hw_version_general_cores": {PerAZ: map[string]int64{"az-1": 2}}, }, }, @@ -394,13 +394,13 @@ func TestIntegration(t *testing.T) { Type: "full_reconcile", ExpectedTotalUsage: map[string]map[string]v1alpha1.ResourceQuotaUsage{ "project-a": { - "hw_version_hana_v2_ram": {PerAZ: map[string]int64{"az-1": 3, "az-2": 1}}, + "hw_version_hana_v2_ram": {PerAZ: map[string]int64{"az-1": 96, "az-2": 32}}, "hw_version_hana_v2_cores": {PerAZ: map[string]int64{"az-1": 24, "az-2": 8}}, - "hw_version_general_ram": {PerAZ: map[string]int64{"az-1": 1}}, + "hw_version_general_ram": {PerAZ: map[string]int64{"az-1": 4}}, "hw_version_general_cores": {PerAZ: map[string]int64{"az-1": 2}}, }, "project-b": { - "hw_version_general_ram": {PerAZ: map[string]int64{"az-1": 1}}, + "hw_version_general_ram": {PerAZ: map[string]int64{"az-1": 4}}, "hw_version_general_cores": {PerAZ: map[string]int64{"az-1": 2}}, }, }, @@ -410,13 +410,13 @@ func TestIntegration(t *testing.T) { Type: "full_reconcile", ExpectedTotalUsage: map[string]map[string]v1alpha1.ResourceQuotaUsage{ "project-a": { - "hw_version_hana_v2_ram": {PerAZ: map[string]int64{"az-1": 3, "az-2": 1}}, + "hw_version_hana_v2_ram": {PerAZ: map[string]int64{"az-1": 96, "az-2": 32}}, "hw_version_hana_v2_cores": {PerAZ: map[string]int64{"az-1": 24, "az-2": 8}}, - "hw_version_general_ram": {PerAZ: map[string]int64{"az-1": 1}}, + "hw_version_general_ram": {PerAZ: map[string]int64{"az-1": 4}}, "hw_version_general_cores": {PerAZ: map[string]int64{"az-1": 2}}, }, "project-b": { - "hw_version_general_ram": {PerAZ: map[string]int64{"az-1": 1}}, + "hw_version_general_ram": {PerAZ: map[string]int64{"az-1": 4}}, "hw_version_general_cores": {PerAZ: map[string]int64{"az-1": 2}}, }, }, @@ -441,9 +441,9 @@ func TestIntegration(t *testing.T) { // PaygUsage == TotalUsage because pending CRs are excluded ExpectedPaygUsage: map[string]map[string]v1alpha1.ResourceQuotaUsage{ "project-a": { - "hw_version_hana_v2_ram": {PerAZ: map[string]int64{"az-1": 3, "az-2": 1}}, + "hw_version_hana_v2_ram": {PerAZ: map[string]int64{"az-1": 96, "az-2": 32}}, "hw_version_hana_v2_cores": {PerAZ: map[string]int64{"az-1": 24, "az-2": 8}}, - "hw_version_general_ram": {PerAZ: map[string]int64{"az-1": 1}}, + "hw_version_general_ram": {PerAZ: map[string]int64{"az-1": 4}}, "hw_version_general_cores": {PerAZ: map[string]int64{"az-1": 2}}, }, }, @@ -463,9 +463,9 @@ func TestIntegration(t *testing.T) { Type: "full_reconcile", ExpectedTotalUsage: map[string]map[string]v1alpha1.ResourceQuotaUsage{ "project-a": { - "hw_version_hana_v2_ram": {PerAZ: map[string]int64{"az-1": 3, "az-2": 1}}, + "hw_version_hana_v2_ram": {PerAZ: map[string]int64{"az-1": 96, "az-2": 32}}, "hw_version_hana_v2_cores": {PerAZ: map[string]int64{"az-1": 24, "az-2": 8}}, - "hw_version_general_ram": {PerAZ: map[string]int64{"az-1": 1}}, + "hw_version_general_ram": {PerAZ: map[string]int64{"az-1": 4}}, "hw_version_general_cores": {PerAZ: map[string]int64{"az-1": 2}}, }, }, @@ -490,7 +490,7 @@ func TestIntegration(t *testing.T) { ProjectID: "project-a", AvailabilityZone: "az-1", CreatedAt: "2099-01-01T00:00:00Z", // after last reconcile Resources: map[string]resource.Quantity{ - "memory": resource.MustParse("34359738368"), // 32768 MiB = 1 RAM unit + "memory": resource.MustParse("34359738368"), // 32768 MiB = 32 GiB "vcpus": resource.MustParse("8"), }, }, @@ -498,9 +498,9 @@ func TestIntegration(t *testing.T) { // TotalUsage now has phantom's contribution (drift) ExpectedTotalUsage: map[string]map[string]v1alpha1.ResourceQuotaUsage{ "project-a": { - "hw_version_hana_v2_ram": {PerAZ: map[string]int64{"az-1": 4, "az-2": 1}}, // 3+1 drift - "hw_version_hana_v2_cores": {PerAZ: map[string]int64{"az-1": 32, "az-2": 8}}, // 24+8 drift - "hw_version_general_ram": {PerAZ: map[string]int64{"az-1": 1}}, + "hw_version_hana_v2_ram": {PerAZ: map[string]int64{"az-1": 128, "az-2": 32}}, // 96+32 drift + "hw_version_hana_v2_cores": {PerAZ: map[string]int64{"az-1": 32, "az-2": 8}}, // 24+8 drift + "hw_version_general_ram": {PerAZ: map[string]int64{"az-1": 4}}, "hw_version_general_cores": {PerAZ: map[string]int64{"az-1": 2}}, }, }, @@ -512,9 +512,9 @@ func TestIntegration(t *testing.T) { OverrideVMs: baseVMsPtr(), ExpectedTotalUsage: map[string]map[string]v1alpha1.ResourceQuotaUsage{ "project-a": { - "hw_version_hana_v2_ram": {PerAZ: map[string]int64{"az-1": 3, "az-2": 1}}, // corrected - "hw_version_hana_v2_cores": {PerAZ: map[string]int64{"az-1": 24, "az-2": 8}}, // corrected - "hw_version_general_ram": {PerAZ: map[string]int64{"az-1": 1}}, + "hw_version_hana_v2_ram": {PerAZ: map[string]int64{"az-1": 96, "az-2": 32}}, // corrected + "hw_version_hana_v2_cores": {PerAZ: map[string]int64{"az-1": 24, "az-2": 8}}, // corrected + "hw_version_general_ram": {PerAZ: map[string]int64{"az-1": 4}}, "hw_version_general_cores": {PerAZ: map[string]int64{"az-1": 2}}, }, }, @@ -544,25 +544,25 @@ func TestIntegration(t *testing.T) { }, Actions: []TestAction{ // Step 1: full reconcile establishes baseline for both projects - // project-a hana_v2: az-1=3 RAM / 24 cores, az-2=1 RAM / 8 cores; general: az-1=1 RAM / 2 cores - // project-b general: az-1=1 RAM / 2 cores + // project-a hana_v2: az-1=96 GiB / 24 cores, az-2=32 GiB / 8 cores; general: az-1=4 GiB / 2 cores + // project-b general: az-1=4 GiB / 2 cores { Type: "full_reconcile", ExpectedTotalUsage: map[string]map[string]v1alpha1.ResourceQuotaUsage{ "project-a": { - "hw_version_hana_v2_ram": {PerAZ: map[string]int64{"az-1": 3, "az-2": 1}}, + "hw_version_hana_v2_ram": {PerAZ: map[string]int64{"az-1": 96, "az-2": 32}}, "hw_version_hana_v2_cores": {PerAZ: map[string]int64{"az-1": 24, "az-2": 8}}, - "hw_version_general_ram": {PerAZ: map[string]int64{"az-1": 1}}, + "hw_version_general_ram": {PerAZ: map[string]int64{"az-1": 4}}, "hw_version_general_cores": {PerAZ: map[string]int64{"az-1": 2}}, }, "project-b": { - "hw_version_general_ram": {PerAZ: map[string]int64{"az-1": 1}}, + "hw_version_general_ram": {PerAZ: map[string]int64{"az-1": 4}}, "hw_version_general_cores": {PerAZ: map[string]int64{"az-1": 2}}, }, }, }, // Step 2: HV diff adds a genuine new VM to project-a (hana_v2 small, az-1) - // +1 RAM unit, +8 cores + // +32 GiB RAM, +8 cores { Type: "hv_diff", OldHV: makeHV("hv-1", []hv1.Instance{ @@ -580,16 +580,16 @@ func TestIntegration(t *testing.T) { ProjectID: "project-a", AvailabilityZone: "az-1", CreatedAt: "2099-01-01T00:00:00Z", Resources: map[string]resource.Quantity{ - "memory": resource.MustParse("34359738368"), // 32768 MiB = 1 RAM unit + "memory": resource.MustParse("34359738368"), // 32768 MiB = 32 GiB "vcpus": resource.MustParse("8"), }, }, ), ExpectedTotalUsage: map[string]map[string]v1alpha1.ResourceQuotaUsage{ "project-a": { - "hw_version_hana_v2_ram": {PerAZ: map[string]int64{"az-1": 4, "az-2": 1}}, + "hw_version_hana_v2_ram": {PerAZ: map[string]int64{"az-1": 128, "az-2": 32}}, "hw_version_hana_v2_cores": {PerAZ: map[string]int64{"az-1": 32, "az-2": 8}}, - "hw_version_general_ram": {PerAZ: map[string]int64{"az-1": 1}}, + "hw_version_general_ram": {PerAZ: map[string]int64{"az-1": 4}}, "hw_version_general_cores": {PerAZ: map[string]int64{"az-1": 2}}, }, }, @@ -611,20 +611,20 @@ func TestIntegration(t *testing.T) { ProjectID: "project-b", AvailabilityZone: "az-1", CreatedAt: "2099-01-01T00:00:00Z", Resources: map[string]resource.Quantity{ - "memory": resource.MustParse("4294967296"), // 4096 MiB = 1 RAM unit + "memory": resource.MustParse("4294967296"), // 4096 MiB = 4 GiB "vcpus": resource.MustParse("2"), }, }, ), ExpectedTotalUsage: map[string]map[string]v1alpha1.ResourceQuotaUsage{ "project-b": { - "hw_version_general_ram": {PerAZ: map[string]int64{"az-1": 2}}, // 1+1 drift + "hw_version_general_ram": {PerAZ: map[string]int64{"az-1": 8}}, // 4+4 drift "hw_version_general_cores": {PerAZ: map[string]int64{"az-1": 4}}, // 2+2 drift }, }, }, // Step 4: HV diff removes vm-del from project-a (truly deleted) - // -1 RAM unit, -8 cores in az-1 + // -32 GiB RAM, -8 cores in az-1 { Type: "hv_diff", OldHV: makeHV("hv-1", []hv1.Instance{ @@ -652,9 +652,9 @@ func TestIntegration(t *testing.T) { ), ExpectedTotalUsage: map[string]map[string]v1alpha1.ResourceQuotaUsage{ "project-a": { - "hw_version_hana_v2_ram": {PerAZ: map[string]int64{"az-1": 3, "az-2": 1}}, // 4-1=3 - "hw_version_hana_v2_cores": {PerAZ: map[string]int64{"az-1": 24, "az-2": 8}}, // 32-8=24 - "hw_version_general_ram": {PerAZ: map[string]int64{"az-1": 1}}, + "hw_version_hana_v2_ram": {PerAZ: map[string]int64{"az-1": 96, "az-2": 32}}, // 128-32=96 + "hw_version_hana_v2_cores": {PerAZ: map[string]int64{"az-1": 24, "az-2": 8}}, // 32-8=24 + "hw_version_general_ram": {PerAZ: map[string]int64{"az-1": 4}}, "hw_version_general_cores": {PerAZ: map[string]int64{"az-1": 2}}, }, }, @@ -681,13 +681,13 @@ func TestIntegration(t *testing.T) { }, ExpectedTotalUsage: map[string]map[string]v1alpha1.ResourceQuotaUsage{ "project-a": { - "hw_version_hana_v2_ram": {PerAZ: map[string]int64{"az-1": 4, "az-2": 1}}, // corrected up - "hw_version_hana_v2_cores": {PerAZ: map[string]int64{"az-1": 32, "az-2": 8}}, // corrected up - "hw_version_general_ram": {PerAZ: map[string]int64{"az-1": 1}}, + "hw_version_hana_v2_ram": {PerAZ: map[string]int64{"az-1": 128, "az-2": 32}}, // corrected up + "hw_version_hana_v2_cores": {PerAZ: map[string]int64{"az-1": 32, "az-2": 8}}, // corrected up + "hw_version_general_ram": {PerAZ: map[string]int64{"az-1": 4}}, "hw_version_general_cores": {PerAZ: map[string]int64{"az-1": 2}}, }, "project-b": { - "hw_version_general_ram": {PerAZ: map[string]int64{"az-1": 1}}, // corrected down + "hw_version_general_ram": {PerAZ: map[string]int64{"az-1": 4}}, // corrected down "hw_version_general_cores": {PerAZ: map[string]int64{"az-1": 2}}, // corrected down }, }, @@ -719,9 +719,9 @@ func TestIntegration(t *testing.T) { // vm-1 migrated, NOT decremented -- totals unchanged ExpectedTotalUsage: map[string]map[string]v1alpha1.ResourceQuotaUsage{ "project-a": { - "hw_version_hana_v2_ram": {PerAZ: map[string]int64{"az-1": 4, "az-2": 1}}, + "hw_version_hana_v2_ram": {PerAZ: map[string]int64{"az-1": 128, "az-2": 32}}, "hw_version_hana_v2_cores": {PerAZ: map[string]int64{"az-1": 32, "az-2": 8}}, - "hw_version_general_ram": {PerAZ: map[string]int64{"az-1": 1}}, + "hw_version_general_ram": {PerAZ: map[string]int64{"az-1": 4}}, "hw_version_general_cores": {PerAZ: map[string]int64{"az-1": 2}}, }, }, @@ -732,13 +732,13 @@ func TestIntegration(t *testing.T) { Type: "full_reconcile", ExpectedTotalUsage: map[string]map[string]v1alpha1.ResourceQuotaUsage{ "project-a": { - "hw_version_hana_v2_ram": {PerAZ: map[string]int64{"az-1": 4, "az-2": 1}}, + "hw_version_hana_v2_ram": {PerAZ: map[string]int64{"az-1": 128, "az-2": 32}}, "hw_version_hana_v2_cores": {PerAZ: map[string]int64{"az-1": 32, "az-2": 8}}, - "hw_version_general_ram": {PerAZ: map[string]int64{"az-1": 1}}, + "hw_version_general_ram": {PerAZ: map[string]int64{"az-1": 4}}, "hw_version_general_cores": {PerAZ: map[string]int64{"az-1": 2}}, }, "project-b": { - "hw_version_general_ram": {PerAZ: map[string]int64{"az-1": 1}}, + "hw_version_general_ram": {PerAZ: map[string]int64{"az-1": 4}}, "hw_version_general_cores": {PerAZ: map[string]int64{"az-1": 2}}, }, }, @@ -789,7 +789,7 @@ var testFlavorGroups = map[string]compute.FlavorGroupFeature{ // project-a has VMs in BOTH flavor groups (hana_v2 and general). // project-b has only general VMs. var testVMs = []failover.VM{ - // vm-1: hana_v2, 1 RAM unit (32768/32768), 8 cores + // vm-1: hana_v2, 32 GiB RAM (32768/1024), 8 cores { UUID: "vm-1", FlavorName: "m1.hana_v2.small", ProjectID: "project-a", AvailabilityZone: "az-1", @@ -799,7 +799,7 @@ var testVMs = []failover.VM{ "vcpus": resource.MustParse("8"), }, }, - // vm-2: hana_v2, 2 RAM units (65536/32768), 16 cores + // vm-2: hana_v2, 64 GiB RAM (65536/1024), 16 cores { UUID: "vm-2", FlavorName: "m1.hana_v2.large", ProjectID: "project-a", AvailabilityZone: "az-1", @@ -809,7 +809,7 @@ var testVMs = []failover.VM{ "vcpus": resource.MustParse("16"), }, }, - // vm-3: hana_v2, 1 RAM unit (32768/32768), 8 cores + // vm-3: hana_v2, 32 GiB RAM (32768/1024), 8 cores { UUID: "vm-3", FlavorName: "m1.hana_v2.small", ProjectID: "project-a", AvailabilityZone: "az-2", @@ -819,7 +819,7 @@ var testVMs = []failover.VM{ "vcpus": resource.MustParse("8"), }, }, - // vm-4: general, 1 RAM unit (4096/4096), 2 cores + // vm-4: general, 4 GiB RAM (4096/1024), 2 cores { UUID: "vm-4", FlavorName: "m1.general.small", ProjectID: "project-a", AvailabilityZone: "az-1", @@ -829,7 +829,7 @@ var testVMs = []failover.VM{ "vcpus": resource.MustParse("2"), }, }, - // vm-5: general, 1 RAM unit (4096/4096), 2 cores + // vm-5: general, 4 GiB RAM (4096/1024), 2 cores { UUID: "vm-5", FlavorName: "m1.general.small", ProjectID: "project-b", AvailabilityZone: "az-1", @@ -1202,16 +1202,15 @@ func makeCR(name, projectID, flavorGroup, az string, resourceType v1alpha1.Commi } // usedResourcesFromMultiples converts a "multiples" value (the old UsedAmount unit) to UsedResources. -// For memory: multiples * smallestFlavorMB * 1024 * 1024 = bytes. +// For memory: multiples * 1 GiB = bytes. // For cores: the value is used directly. func usedResourcesFromMultiples(resourceType v1alpha1.CommittedResourceType, flavorGroup string, multiples int64) map[string]resource.Quantity { switch resourceType { case v1alpha1.CommittedResourceTypeMemory: - fg, ok := testFlavorGroups[flavorGroup] - if !ok || fg.SmallestFlavor.MemoryMB == 0 { + if _, ok := testFlavorGroups[flavorGroup]; !ok { return nil } - bytesVal := multiples * int64(fg.SmallestFlavor.MemoryMB) * 1024 * 1024 //nolint:gosec // test only + bytesVal := multiples * 1024 * 1024 * 1024 return map[string]resource.Quantity{ "memory": *resource.NewQuantity(bytesVal, resource.BinarySI), } diff --git a/tools/visualize-committed-resources/main.go b/tools/visualize-committed-resources/main.go index f722f8b8d..88a23bbe4 100644 --- a/tools/visualize-committed-resources/main.go +++ b/tools/visualize-committed-resources/main.go @@ -9,7 +9,7 @@ // // Flags: // -// --context=ctx Kubernetes context (default: current context) +// --contexts=ctx1,ctx2 Kubernetes contexts to query (default: current context) // --filter-project=id Show only CRs for this project ID (substring match) // --filter-az=az Show only CRs in this availability zone (substring match) // --filter-group=name Show only CRs for this flavor group (substring match) @@ -37,7 +37,6 @@ import ( utilruntime "k8s.io/apimachinery/pkg/util/runtime" "k8s.io/client-go/tools/clientcmd" "sigs.k8s.io/controller-runtime/pkg/client" - "sigs.k8s.io/controller-runtime/pkg/client/config" ) var scheme = runtime.NewScheme() @@ -106,17 +105,9 @@ func (vs viewSet) has(v string) bool { return vs[v] } // ── k8s client ──────────────────────────────────────────────────────────────── -func newClient(contextName string) (client.Client, error) { - if contextName == "" { - c, err := config.GetConfig() - if err != nil { - return nil, fmt.Errorf("getting kubeconfig: %w", err) - } - return client.New(c, client.Options{Scheme: scheme}) - } - loadingRules := clientcmd.NewDefaultClientConfigLoadingRules() +func getClientForContext(contextName string) (client.Client, error) { kubeConfig := clientcmd.NewNonInteractiveDeferredLoadingClientConfig( - loadingRules, + clientcmd.NewDefaultClientConfigLoadingRules(), &clientcmd.ConfigOverrides{CurrentContext: contextName}, ) c, err := kubeConfig.ClientConfig() @@ -126,6 +117,18 @@ func newClient(contextName string) (client.Client, error) { return client.New(c, client.Options{Scheme: scheme}) } +type contextClient struct { + name string + client client.Client +} + +func contextDisplayName(ctx string) string { + if ctx == "" { + return "(current)" + } + return ctx +} + // ── helpers ─────────────────────────────────────────────────────────────────── func printHeader(title string) { @@ -466,7 +469,7 @@ func printReservations(crs []v1alpha1.CommittedResource, reservations []v1alpha1 // ── main ────────────────────────────────────────────────────────────────────── func main() { - k8sContext := flag.String("context", "", "Kubernetes context (default: current context)") + contextsFlag := flag.String("contexts", "", "Comma-separated Kubernetes contexts to query (default: current context)") filterProject := flag.String("filter-project", "", "Show only CRs for this project ID (substring match)") filterAZ := flag.String("filter-az", "", "Show only CRs in this availability zone (substring match)") filterGroup := flag.String("filter-group", "", "Show only CRs for this flavor group (substring match)") @@ -489,17 +492,28 @@ func main() { active: *activeOnly, } - cl, err := newClient(*k8sContext) - if err != nil { - fmt.Fprintf(os.Stderr, "error: %v\n", err) - os.Exit(1) + contextNames := []string{""} + if *contextsFlag != "" { + contextNames = strings.FieldsFunc(*contextsFlag, func(r rune) bool { return r == ',' }) + for i := range contextNames { + contextNames[i] = strings.TrimSpace(contextNames[i]) + } + } + var clients []contextClient + for _, name := range contextNames { + cl, err := getClientForContext(name) + if err != nil { + fmt.Fprintf(os.Stderr, "error creating client for context %q: %v\n", contextDisplayName(name), err) + os.Exit(1) + } + clients = append(clients, contextClient{name: name, client: cl}) } ctx := context.Background() var prevDigest string first := true for { - crs, reservations := fetchSnapshot(ctx, cl, f, *limitFlag) + crs, reservations := fetchSnapshot(ctx, clients, f, *limitFlag) if d := snapshotDigest(crs, reservations); first || d != prevDigest { if !first { fmt.Printf("\n%s %s %s\n", @@ -531,59 +545,75 @@ func snapshotDigest(crs []v1alpha1.CommittedResource, reservations []v1alpha1.Re return b.String() } -func fetchSnapshot(ctx context.Context, cl client.Client, f filters, limit int) ([]v1alpha1.CommittedResource, []v1alpha1.Reservation) { - var listOpts []client.ListOption - if limit > 0 { - listOpts = append(listOpts, client.Limit(int64(limit))) - } +func fetchSnapshot(ctx context.Context, clients []contextClient, f filters, limit int) ([]v1alpha1.CommittedResource, []v1alpha1.Reservation) { + multiContext := len(clients) > 1 - var crList v1alpha1.CommittedResourceList - if err := cl.List(ctx, &crList, listOpts...); err != nil { - fmt.Fprintf(os.Stderr, "error listing CommittedResources: %v\n", err) - os.Exit(1) - } + var allCRs []v1alpha1.CommittedResource + var allReservations []v1alpha1.Reservation - var resList v1alpha1.ReservationList - if err := cl.List(ctx, &resList, append(listOpts, client.MatchingLabels{ - v1alpha1.LabelReservationType: v1alpha1.ReservationTypeLabelCommittedResource, - })...); err != nil { - fmt.Fprintf(os.Stderr, "error listing Reservations: %v\n", err) - os.Exit(1) - } + for _, cc := range clients { + var listOpts []client.ListOption + if limit > 0 { + listOpts = append(listOpts, client.Limit(int64(limit))) + } - if crList.Continue != "" { - fmt.Fprintf(os.Stderr, yellow("warning: CR list truncated at %d — use --limit=0 or a higher value to see all\n"), limit) - } - if resList.Continue != "" { - fmt.Fprintf(os.Stderr, yellow("warning: Reservation list truncated at %d — use --limit=0 or a higher value to see all\n"), limit) - } - var crs []v1alpha1.CommittedResource - for _, cr := range crList.Items { - if f.match(cr) { - crs = append(crs, cr) + var crList v1alpha1.CommittedResourceList + if err := cc.client.List(ctx, &crList, listOpts...); err != nil { + fmt.Fprintf(os.Stderr, "warning: error listing CommittedResources in context %q: %v\n", contextDisplayName(cc.name), err) + continue + } + var resList v1alpha1.ReservationList + if err := cc.client.List(ctx, &resList, append(listOpts, client.MatchingLabels{ + v1alpha1.LabelReservationType: v1alpha1.ReservationTypeLabelCommittedResource, + })...); err != nil { + fmt.Fprintf(os.Stderr, "warning: error listing Reservations in context %q: %v\n", contextDisplayName(cc.name), err) + continue + } + + if crList.Continue != "" { + fmt.Fprintf(os.Stderr, yellow("warning: CR list truncated at %d in context %q — use --limit=0 or a higher value\n"), limit, contextDisplayName(cc.name)) + } + if resList.Continue != "" { + fmt.Fprintf(os.Stderr, yellow("warning: Reservation list truncated at %d in context %q — use --limit=0 or a higher value\n"), limit, contextDisplayName(cc.name)) + } + + for _, cr := range crList.Items { + if f.match(cr) { + if multiContext { + cr.Name = cr.Name + "@" + contextDisplayName(cc.name) + } + allCRs = append(allCRs, cr) + } + } + for _, res := range resList.Items { + if res.Spec.CommittedResourceReservation == nil { + continue + } + if multiContext { + res.Name = res.Name + "@" + contextDisplayName(cc.name) + } + allReservations = append(allReservations, res) } } - sort.Slice(crs, func(i, j int) bool { - if crs[i].Spec.FlavorGroupName != crs[j].Spec.FlavorGroupName { - return crs[i].Spec.FlavorGroupName < crs[j].Spec.FlavorGroupName + + sort.Slice(allCRs, func(i, j int) bool { + if allCRs[i].Spec.FlavorGroupName != allCRs[j].Spec.FlavorGroupName { + return allCRs[i].Spec.FlavorGroupName < allCRs[j].Spec.FlavorGroupName } - return crs[i].Spec.CommitmentUUID < crs[j].Spec.CommitmentUUID + return allCRs[i].Spec.CommitmentUUID < allCRs[j].Spec.CommitmentUUID }) - matchedUUIDs := make(map[string]bool, len(crs)) - for _, cr := range crs { + matchedUUIDs := make(map[string]bool, len(allCRs)) + for _, cr := range allCRs { matchedUUIDs[cr.Spec.CommitmentUUID] = true } var reservations []v1alpha1.Reservation - for _, res := range resList.Items { - if res.Spec.CommittedResourceReservation == nil { - continue - } + for _, res := range allReservations { if matchedUUIDs[res.Spec.CommittedResourceReservation.CommitmentUUID] { reservations = append(reservations, res) } } - return crs, reservations + return allCRs, reservations } func printSnapshot(crs []v1alpha1.CommittedResource, reservations []v1alpha1.Reservation, f filters, views viewSet) {