From 7e29fbd87a558cc4c23273749e0ff2625323ef13 Mon Sep 17 00:00:00 2001 From: Malte <140147670+umswmayj@users.noreply.github.com> Date: Mon, 11 May 2026 14:35:20 +0200 Subject: [PATCH 1/7] feat(quota): track instance count (VM count) per project/AZ (#837) Add _instances resource tracking to the quota controller alongside the existing _ram and _cores resources. Each VM counts as 1 instance per project/AZ/flavorGroup. Changes: - computeTotalUsage: accumulate hw_version__instances (+1 per VM) - accumulateAddedVM: add instances delta (+1) for new VMs - accumulateRemovedVM: add instances decrement (-1) for deleted VMs - Add EnableHVDiff config flag (default: true) to disable incremental HV diff calculation when needed (e.g. for debugging or QA) - Update unit tests and integration tests with instances expectations Since there are no CommittedResources for instances, PaygUsage for instances naturally equals TotalUsage (no CR deduction needed). To disable the HV diff watcher, set enableHVDiff: false in the quota controller config. Only periodic full reconciles will update TotalUsage. --- .../scheduling/reservations/quota/config.go | 18 + .../reservations/quota/controller.go | 17 +- .../reservations/quota/controller_test.go | 20 +- .../reservations/quota/integration_test.go | 350 +++++++++++------- 4 files changed, 261 insertions(+), 144 deletions(-) diff --git a/internal/scheduling/reservations/quota/config.go b/internal/scheduling/reservations/quota/config.go index b7314f595..706936872 100644 --- a/internal/scheduling/reservations/quota/config.go +++ b/internal/scheduling/reservations/quota/config.go @@ -19,6 +19,11 @@ type QuotaControllerConfig struct { // CRStateFilter defines which CommittedResource states to include // when summing cr_actual_usage. Default: ["confirmed", "guaranteed"] CRStateFilter []v1alpha1.CommitmentStatus `json:"crStateFilter"` + + // EnableHVDiff enables incremental TotalUsage updates via HV instance diffs. + // When false, only periodic full reconciles update TotalUsage (safer but slower convergence). + // Default: true. + EnableHVDiff *bool `json:"enableHVDiff,omitempty"` } // ApplyDefaults fills in any unset values with defaults. @@ -30,15 +35,28 @@ func (c *QuotaControllerConfig) ApplyDefaults() { if len(c.CRStateFilter) == 0 { c.CRStateFilter = defaults.CRStateFilter } + if c.EnableHVDiff == nil { + c.EnableHVDiff = defaults.EnableHVDiff + } +} + +// IsHVDiffEnabled returns whether incremental HV diff is enabled. +func (c *QuotaControllerConfig) IsHVDiffEnabled() bool { + if c.EnableHVDiff == nil { + return true // default: enabled + } + return *c.EnableHVDiff } // DefaultQuotaControllerConfig returns a default configuration. func DefaultQuotaControllerConfig() QuotaControllerConfig { + enableHVDiff := true return QuotaControllerConfig{ FullReconcileInterval: metav1.Duration{Duration: 5 * time.Minute}, CRStateFilter: []v1alpha1.CommitmentStatus{ v1alpha1.CommitmentStatusConfirmed, v1alpha1.CommitmentStatusGuaranteed, }, + EnableHVDiff: &enableHVDiff, } } diff --git a/internal/scheduling/reservations/quota/controller.go b/internal/scheduling/reservations/quota/controller.go index f374080bc..2aff199e3 100644 --- a/internal/scheduling/reservations/quota/controller.go +++ b/internal/scheduling/reservations/quota/controller.go @@ -435,6 +435,7 @@ func (c *QuotaController) accumulateAddedVM( delta.addIncrement(commitments.ResourceNameRAM(groupName), vm.AvailabilityZone, ramUnits) delta.addIncrement(commitments.ResourceNameCores(groupName), vm.AvailabilityZone, coresAmount) + delta.addIncrement(commitments.ResourceNameInstances(groupName), vm.AvailabilityZone, 1) } // isVMNewSinceLastReconcile checks if a VM was created after the last full reconcile. @@ -536,6 +537,7 @@ func (c *QuotaController) accumulateRemovedVM( delta.addDecrement(commitments.ResourceNameRAM(groupName), info.AvailabilityZone, ramUnits) delta.addDecrement(commitments.ResourceNameCores(groupName), info.AvailabilityZone, coresAmount) + delta.addDecrement(commitments.ResourceNameInstances(groupName), info.AvailabilityZone, 1) } // applyDeltaAndUpdateStatus applies batched deltas to ALL per-AZ ProjectQuota CRDs for a project. @@ -648,8 +650,12 @@ func (c *QuotaController) SetupWithManager(mgr ctrl.Manager) error { } // SetupHVWatcher sets up a separate controller to watch HV CRD changes -// for incremental TotalUsage updates. +// for incremental TotalUsage updates. Skipped if EnableHVDiff is false. func (c *QuotaController) SetupHVWatcher(mgr ctrl.Manager) error { + if !c.Config.IsHVDiffEnabled() { + log.Info("HV diff watcher disabled by config (enableHVDiff=false)") + return nil + } return ctrl.NewControllerManagedBy(mgr). Named("quota-hv-watcher"). WatchesRawSource(source.Kind( @@ -726,6 +732,7 @@ func (c *QuotaController) computeTotalUsage( ramResourceName := commitments.ResourceNameRAM(groupName) coresResourceName := commitments.ResourceNameCores(groupName) + instancesResourceName := commitments.ResourceNameInstances(groupName) ramUnits, coresAmount := vmResourceUnits(vm.Resources) @@ -748,6 +755,14 @@ func (c *QuotaController) computeTotalUsage( } coresUsage[vm.AvailabilityZone] += coresAmount result[vm.ProjectID][coresResourceName] = coresUsage + + // Accumulate instances usage for this project + AZ (1 per VM) + instancesUsage := result[vm.ProjectID][instancesResourceName] + if instancesUsage == nil { + instancesUsage = make(map[string]int64) + } + instancesUsage[vm.AvailabilityZone]++ + result[vm.ProjectID][instancesResourceName] = instancesUsage } return result diff --git a/internal/scheduling/reservations/quota/controller_test.go b/internal/scheduling/reservations/quota/controller_test.go index b5b724647..2becea2db 100644 --- a/internal/scheduling/reservations/quota/controller_test.go +++ b/internal/scheduling/reservations/quota/controller_test.go @@ -93,8 +93,8 @@ func TestComputeTotalUsage(t *testing.T) { result := ctrl.computeTotalUsage(vms, flavorToGroup, flavorGroups) - // 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 + // project-a: hana_v2 in az-1: (32768+65536)/1024 = 96 GiB RAM, 8+16=24 cores, 2 instances + // project-a: hana_v2 in az-2: 32768/1024 = 32 GiB RAM, 8 cores, 1 instance projectA := result["project-a"] if projectA == nil { t.Fatal("expected project-a in results") @@ -116,7 +116,15 @@ func TestComputeTotalUsage(t *testing.T) { t.Errorf("expected project-a az-2 hana_v2_cores = 8, got %d", coresUsage["az-2"]) } - // project-b: general in az-1: 4096/1024 = 4 GiB RAM, 2 cores + instancesUsage := projectA["hw_version_hana_v2_instances"] + if instancesUsage["az-1"] != 2 { + t.Errorf("expected project-a az-1 hana_v2_instances = 2, got %d", instancesUsage["az-1"]) + } + if instancesUsage["az-2"] != 1 { + t.Errorf("expected project-a az-2 hana_v2_instances = 1, got %d", instancesUsage["az-2"]) + } + + // project-b: general in az-1: 4096/1024 = 4 GiB RAM, 2 cores, 1 instance projectB := result["project-b"] if projectB == nil { t.Fatal("expected project-b in results") @@ -127,6 +135,9 @@ func TestComputeTotalUsage(t *testing.T) { if projectB["hw_version_general_cores"]["az-1"] != 2 { t.Errorf("expected project-b az-1 general_cores = 2, got %d", projectB["hw_version_general_cores"]["az-1"]) } + if projectB["hw_version_general_instances"]["az-1"] != 1 { + t.Errorf("expected project-b az-1 general_instances = 1, got %d", projectB["hw_version_general_instances"]["az-1"]) + } // project-c: unknown flavor → not in results if _, exists := result["project-c"]; exists { @@ -560,6 +571,9 @@ func TestAccumulateAddedVM_KnownFlavor(t *testing.T) { 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"]) } + if delta.increments["hw_version_hana_v2_instances"]["az-1"] != 1 { + t.Errorf("expected instances increment = 1, got %d", delta.increments["hw_version_hana_v2_instances"]["az-1"]) + } } // mockVMSource is a test helper for VMSource. diff --git a/internal/scheduling/reservations/quota/integration_test.go b/internal/scheduling/reservations/quota/integration_test.go index dbe174f69..d960ab396 100644 --- a/internal/scheduling/reservations/quota/integration_test.go +++ b/internal/scheduling/reservations/quota/integration_test.go @@ -47,27 +47,33 @@ func TestIntegration(t *testing.T) { // project-b: general az-1: 4096/1024 = 4 GiB, 2 cores ExpectedTotalUsage: map[string]map[string]map[string]int64{ "project-a": { - "hw_version_hana_v2_ram": {"az-1": 96, "az-2": 32}, - "hw_version_hana_v2_cores": {"az-1": 24, "az-2": 8}, - "hw_version_general_ram": {"az-1": 4}, - "hw_version_general_cores": {"az-1": 2}, + "hw_version_hana_v2_ram": {"az-1": 96, "az-2": 32}, + "hw_version_hana_v2_cores": {"az-1": 24, "az-2": 8}, + "hw_version_hana_v2_instances": {"az-1": 2, "az-2": 1}, + "hw_version_general_ram": {"az-1": 4}, + "hw_version_general_cores": {"az-1": 2}, + "hw_version_general_instances": {"az-1": 1}, }, "project-b": { - "hw_version_general_ram": {"az-1": 4}, - "hw_version_general_cores": {"az-1": 2}, + "hw_version_general_ram": {"az-1": 4}, + "hw_version_general_cores": {"az-1": 2}, + "hw_version_general_instances": {"az-1": 1}, }, }, // No CRs -> PaygUsage == TotalUsage ExpectedPaygUsage: map[string]map[string]map[string]int64{ "project-a": { - "hw_version_hana_v2_ram": {"az-1": 96, "az-2": 32}, - "hw_version_hana_v2_cores": {"az-1": 24, "az-2": 8}, - "hw_version_general_ram": {"az-1": 4}, - "hw_version_general_cores": {"az-1": 2}, + "hw_version_hana_v2_ram": {"az-1": 96, "az-2": 32}, + "hw_version_hana_v2_cores": {"az-1": 24, "az-2": 8}, + "hw_version_hana_v2_instances": {"az-1": 2, "az-2": 1}, + "hw_version_general_ram": {"az-1": 4}, + "hw_version_general_cores": {"az-1": 2}, + "hw_version_general_instances": {"az-1": 1}, }, "project-b": { - "hw_version_general_ram": {"az-1": 4}, - "hw_version_general_cores": {"az-1": 2}, + "hw_version_general_ram": {"az-1": 4}, + "hw_version_general_cores": {"az-1": 2}, + "hw_version_general_instances": {"az-1": 1}, }, }, }, @@ -94,10 +100,12 @@ func TestIntegration(t *testing.T) { Type: "full_reconcile", ExpectedTotalUsage: map[string]map[string]map[string]int64{ "project-a": { - "hw_version_hana_v2_ram": {"az-1": 96, "az-2": 32}, - "hw_version_hana_v2_cores": {"az-1": 24, "az-2": 8}, - "hw_version_general_ram": {"az-1": 4}, - "hw_version_general_cores": {"az-1": 2}, + "hw_version_hana_v2_ram": {"az-1": 96, "az-2": 32}, + "hw_version_hana_v2_cores": {"az-1": 24, "az-2": 8}, + "hw_version_hana_v2_instances": {"az-1": 2, "az-2": 1}, + "hw_version_general_ram": {"az-1": 4}, + "hw_version_general_cores": {"az-1": 2}, + "hw_version_general_instances": {"az-1": 1}, }, }, // PaygUsage = TotalUsage - CRUsage @@ -106,10 +114,12 @@ func TestIntegration(t *testing.T) { // general: no CRs so PaygUsage == TotalUsage ExpectedPaygUsage: map[string]map[string]map[string]int64{ "project-a": { - "hw_version_hana_v2_ram": {"az-1": 94, "az-2": 32}, - "hw_version_hana_v2_cores": {"az-1": 14, "az-2": 8}, - "hw_version_general_ram": {"az-1": 4}, - "hw_version_general_cores": {"az-1": 2}, + "hw_version_hana_v2_ram": {"az-1": 94, "az-2": 32}, + "hw_version_hana_v2_cores": {"az-1": 14, "az-2": 8}, + "hw_version_hana_v2_instances": {"az-1": 2, "az-2": 1}, + "hw_version_general_ram": {"az-1": 4}, + "hw_version_general_cores": {"az-1": 2}, + "hw_version_general_instances": {"az-1": 1}, }, }, }, @@ -129,10 +139,12 @@ func TestIntegration(t *testing.T) { Type: "full_reconcile", ExpectedTotalUsage: map[string]map[string]map[string]int64{ "project-a": { - "hw_version_hana_v2_ram": {"az-1": 96, "az-2": 32}, - "hw_version_hana_v2_cores": {"az-1": 24, "az-2": 8}, - "hw_version_general_ram": {"az-1": 4}, - "hw_version_general_cores": {"az-1": 2}, + "hw_version_hana_v2_ram": {"az-1": 96, "az-2": 32}, + "hw_version_hana_v2_cores": {"az-1": 24, "az-2": 8}, + "hw_version_hana_v2_instances": {"az-1": 2, "az-2": 1}, + "hw_version_general_ram": {"az-1": 4}, + "hw_version_general_cores": {"az-1": 2}, + "hw_version_general_instances": {"az-1": 1}, }, }, }, @@ -163,10 +175,12 @@ func TestIntegration(t *testing.T) { // +32 GiB RAM (32768/1024), +8 cores ExpectedTotalUsage: map[string]map[string]map[string]int64{ "project-a": { - "hw_version_hana_v2_ram": {"az-1": 128, "az-2": 32}, - "hw_version_hana_v2_cores": {"az-1": 32, "az-2": 8}, - "hw_version_general_ram": {"az-1": 4}, - "hw_version_general_cores": {"az-1": 2}, + "hw_version_hana_v2_ram": {"az-1": 128, "az-2": 32}, + "hw_version_hana_v2_cores": {"az-1": 32, "az-2": 8}, + "hw_version_hana_v2_instances": {"az-1": 3, "az-2": 1}, + "hw_version_general_ram": {"az-1": 4}, + "hw_version_general_cores": {"az-1": 2}, + "hw_version_general_instances": {"az-1": 1}, }, }, }, @@ -186,10 +200,12 @@ func TestIntegration(t *testing.T) { Type: "full_reconcile", ExpectedTotalUsage: map[string]map[string]map[string]int64{ "project-a": { - "hw_version_hana_v2_ram": {"az-1": 96, "az-2": 32}, - "hw_version_hana_v2_cores": {"az-1": 24, "az-2": 8}, - "hw_version_general_ram": {"az-1": 4}, - "hw_version_general_cores": {"az-1": 2}, + "hw_version_hana_v2_ram": {"az-1": 96, "az-2": 32}, + "hw_version_hana_v2_cores": {"az-1": 24, "az-2": 8}, + "hw_version_hana_v2_instances": {"az-1": 2, "az-2": 1}, + "hw_version_general_ram": {"az-1": 4}, + "hw_version_general_cores": {"az-1": 2}, + "hw_version_general_instances": {"az-1": 1}, }, }, }, @@ -203,10 +219,12 @@ 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]map[string]int64{ "project-a": { - "hw_version_hana_v2_ram": {"az-1": 96, "az-2": 32}, - "hw_version_hana_v2_cores": {"az-1": 24, "az-2": 8}, - "hw_version_general_ram": {"az-1": 4}, - "hw_version_general_cores": {"az-1": 2}, + "hw_version_hana_v2_ram": {"az-1": 96, "az-2": 32}, + "hw_version_hana_v2_cores": {"az-1": 24, "az-2": 8}, + "hw_version_hana_v2_instances": {"az-1": 2, "az-2": 1}, + "hw_version_general_ram": {"az-1": 4}, + "hw_version_general_cores": {"az-1": 2}, + "hw_version_general_instances": {"az-1": 1}, }, }, }, @@ -239,10 +257,12 @@ func TestIntegration(t *testing.T) { Type: "full_reconcile", ExpectedTotalUsage: map[string]map[string]map[string]int64{ "project-a": { - "hw_version_hana_v2_ram": {"az-1": 96, "az-2": 32}, - "hw_version_hana_v2_cores": {"az-1": 24, "az-2": 8}, - "hw_version_general_ram": {"az-1": 4}, - "hw_version_general_cores": {"az-1": 2}, + "hw_version_hana_v2_ram": {"az-1": 96, "az-2": 32}, + "hw_version_hana_v2_cores": {"az-1": 24, "az-2": 8}, + "hw_version_hana_v2_instances": {"az-1": 2, "az-2": 1}, + "hw_version_general_ram": {"az-1": 4}, + "hw_version_general_cores": {"az-1": 2}, + "hw_version_general_instances": {"az-1": 1}, }, }, }, @@ -263,10 +283,12 @@ func TestIntegration(t *testing.T) { // Decrement: -32 GiB RAM, -8 cores in az-1 ExpectedTotalUsage: map[string]map[string]map[string]int64{ "project-a": { - "hw_version_hana_v2_ram": {"az-1": 64, "az-2": 32}, - "hw_version_hana_v2_cores": {"az-1": 16, "az-2": 8}, - "hw_version_general_ram": {"az-1": 4}, - "hw_version_general_cores": {"az-1": 2}, + "hw_version_hana_v2_ram": {"az-1": 64, "az-2": 32}, + "hw_version_hana_v2_cores": {"az-1": 16, "az-2": 8}, + "hw_version_hana_v2_instances": {"az-1": 1, "az-2": 1}, + "hw_version_general_ram": {"az-1": 4}, + "hw_version_general_cores": {"az-1": 2}, + "hw_version_general_instances": {"az-1": 1}, }, }, }, @@ -288,10 +310,12 @@ func TestIntegration(t *testing.T) { Type: "full_reconcile", ExpectedTotalUsage: map[string]map[string]map[string]int64{ "project-a": { - "hw_version_hana_v2_ram": {"az-1": 96, "az-2": 32}, - "hw_version_hana_v2_cores": {"az-1": 24, "az-2": 8}, - "hw_version_general_ram": {"az-1": 4}, - "hw_version_general_cores": {"az-1": 2}, + "hw_version_hana_v2_ram": {"az-1": 96, "az-2": 32}, + "hw_version_hana_v2_cores": {"az-1": 24, "az-2": 8}, + "hw_version_hana_v2_instances": {"az-1": 2, "az-2": 1}, + "hw_version_general_ram": {"az-1": 4}, + "hw_version_general_cores": {"az-1": 2}, + "hw_version_general_instances": {"az-1": 1}, }, }, }, @@ -309,10 +333,12 @@ func TestIntegration(t *testing.T) { // vm-1: IsServerActive=true, so NOT decremented ExpectedTotalUsage: map[string]map[string]map[string]int64{ "project-a": { - "hw_version_hana_v2_ram": {"az-1": 96, "az-2": 32}, - "hw_version_hana_v2_cores": {"az-1": 24, "az-2": 8}, - "hw_version_general_ram": {"az-1": 4}, - "hw_version_general_cores": {"az-1": 2}, + "hw_version_hana_v2_ram": {"az-1": 96, "az-2": 32}, + "hw_version_hana_v2_cores": {"az-1": 24, "az-2": 8}, + "hw_version_hana_v2_instances": {"az-1": 2, "az-2": 1}, + "hw_version_general_ram": {"az-1": 4}, + "hw_version_general_cores": {"az-1": 2}, + "hw_version_general_instances": {"az-1": 1}, }, }, }, @@ -336,10 +362,12 @@ func TestIntegration(t *testing.T) { Type: "full_reconcile", ExpectedPaygUsage: map[string]map[string]map[string]int64{ "project-a": { - "hw_version_hana_v2_ram": {"az-1": 95, "az-2": 32}, // 96-1=95 - "hw_version_hana_v2_cores": {"az-1": 24, "az-2": 8}, - "hw_version_general_ram": {"az-1": 4}, - "hw_version_general_cores": {"az-1": 2}, + "hw_version_hana_v2_ram": {"az-1": 95, "az-2": 32}, // 96-1=95 + "hw_version_hana_v2_cores": {"az-1": 24, "az-2": 8}, + "hw_version_hana_v2_instances": {"az-1": 2, "az-2": 1}, + "hw_version_general_ram": {"az-1": 4}, + "hw_version_general_cores": {"az-1": 2}, + "hw_version_general_instances": {"az-1": 1}, }, }, }, @@ -350,10 +378,12 @@ func TestIntegration(t *testing.T) { UsedAmount: 3, ExpectedPaygUsage: map[string]map[string]map[string]int64{ "project-a": { - "hw_version_hana_v2_ram": {"az-1": 93, "az-2": 32}, // 96-3=93 - "hw_version_hana_v2_cores": {"az-1": 24, "az-2": 8}, - "hw_version_general_ram": {"az-1": 4}, - "hw_version_general_cores": {"az-1": 2}, + "hw_version_hana_v2_ram": {"az-1": 93, "az-2": 32}, // 96-3=93 + "hw_version_hana_v2_cores": {"az-1": 24, "az-2": 8}, + "hw_version_hana_v2_instances": {"az-1": 2, "az-2": 1}, + "hw_version_general_ram": {"az-1": 4}, + "hw_version_general_cores": {"az-1": 2}, + "hw_version_general_instances": {"az-1": 1}, }, }, }, @@ -402,14 +432,17 @@ func TestIntegration(t *testing.T) { Type: "full_reconcile", ExpectedTotalUsage: map[string]map[string]map[string]int64{ "project-a": { - "hw_version_hana_v2_ram": {"az-1": 96, "az-2": 32}, - "hw_version_hana_v2_cores": {"az-1": 24, "az-2": 8}, - "hw_version_general_ram": {"az-1": 4}, - "hw_version_general_cores": {"az-1": 2}, + "hw_version_hana_v2_ram": {"az-1": 96, "az-2": 32}, + "hw_version_hana_v2_cores": {"az-1": 24, "az-2": 8}, + "hw_version_hana_v2_instances": {"az-1": 2, "az-2": 1}, + "hw_version_general_ram": {"az-1": 4}, + "hw_version_general_cores": {"az-1": 2}, + "hw_version_general_instances": {"az-1": 1}, }, "project-b": { - "hw_version_general_ram": {"az-1": 4}, - "hw_version_general_cores": {"az-1": 2}, + "hw_version_general_ram": {"az-1": 4}, + "hw_version_general_cores": {"az-1": 2}, + "hw_version_general_instances": {"az-1": 1}, }, }, }, @@ -418,14 +451,17 @@ func TestIntegration(t *testing.T) { Type: "full_reconcile", ExpectedTotalUsage: map[string]map[string]map[string]int64{ "project-a": { - "hw_version_hana_v2_ram": {"az-1": 96, "az-2": 32}, - "hw_version_hana_v2_cores": {"az-1": 24, "az-2": 8}, - "hw_version_general_ram": {"az-1": 4}, - "hw_version_general_cores": {"az-1": 2}, + "hw_version_hana_v2_ram": {"az-1": 96, "az-2": 32}, + "hw_version_hana_v2_cores": {"az-1": 24, "az-2": 8}, + "hw_version_hana_v2_instances": {"az-1": 2, "az-2": 1}, + "hw_version_general_ram": {"az-1": 4}, + "hw_version_general_cores": {"az-1": 2}, + "hw_version_general_instances": {"az-1": 1}, }, "project-b": { - "hw_version_general_ram": {"az-1": 4}, - "hw_version_general_cores": {"az-1": 2}, + "hw_version_general_ram": {"az-1": 4}, + "hw_version_general_cores": {"az-1": 2}, + "hw_version_general_instances": {"az-1": 1}, }, }, }, @@ -450,10 +486,12 @@ func TestIntegration(t *testing.T) { // PaygUsage == TotalUsage because pending CRs are excluded ExpectedPaygUsage: map[string]map[string]map[string]int64{ "project-a": { - "hw_version_hana_v2_ram": {"az-1": 96, "az-2": 32}, - "hw_version_hana_v2_cores": {"az-1": 24, "az-2": 8}, - "hw_version_general_ram": {"az-1": 4}, - "hw_version_general_cores": {"az-1": 2}, + "hw_version_hana_v2_ram": {"az-1": 96, "az-2": 32}, + "hw_version_hana_v2_cores": {"az-1": 24, "az-2": 8}, + "hw_version_hana_v2_instances": {"az-1": 2, "az-2": 1}, + "hw_version_general_ram": {"az-1": 4}, + "hw_version_general_cores": {"az-1": 2}, + "hw_version_general_instances": {"az-1": 1}, }, }, }, @@ -473,10 +511,12 @@ func TestIntegration(t *testing.T) { Type: "full_reconcile", ExpectedTotalUsage: map[string]map[string]map[string]int64{ "project-a": { - "hw_version_hana_v2_ram": {"az-1": 96, "az-2": 32}, - "hw_version_hana_v2_cores": {"az-1": 24, "az-2": 8}, - "hw_version_general_ram": {"az-1": 4}, - "hw_version_general_cores": {"az-1": 2}, + "hw_version_hana_v2_ram": {"az-1": 96, "az-2": 32}, + "hw_version_hana_v2_cores": {"az-1": 24, "az-2": 8}, + "hw_version_hana_v2_instances": {"az-1": 2, "az-2": 1}, + "hw_version_general_ram": {"az-1": 4}, + "hw_version_general_cores": {"az-1": 2}, + "hw_version_general_instances": {"az-1": 1}, }, }, }, @@ -508,10 +548,12 @@ func TestIntegration(t *testing.T) { // TotalUsage now has phantom's contribution (drift) ExpectedTotalUsage: map[string]map[string]map[string]int64{ "project-a": { - "hw_version_hana_v2_ram": {"az-1": 128, "az-2": 32}, // 96+32 drift - "hw_version_hana_v2_cores": {"az-1": 32, "az-2": 8}, // 24+8 drift - "hw_version_general_ram": {"az-1": 4}, - "hw_version_general_cores": {"az-1": 2}, + "hw_version_hana_v2_ram": {"az-1": 128, "az-2": 32}, // 96+32 drift + "hw_version_hana_v2_cores": {"az-1": 32, "az-2": 8}, // 24+8 drift + "hw_version_hana_v2_instances": {"az-1": 3, "az-2": 1}, + "hw_version_general_ram": {"az-1": 4}, + "hw_version_general_cores": {"az-1": 2}, + "hw_version_general_instances": {"az-1": 1}, }, }, }, @@ -522,10 +564,12 @@ func TestIntegration(t *testing.T) { OverrideVMs: baseVMsPtr(), ExpectedTotalUsage: map[string]map[string]map[string]int64{ "project-a": { - "hw_version_hana_v2_ram": {"az-1": 96, "az-2": 32}, // corrected - "hw_version_hana_v2_cores": {"az-1": 24, "az-2": 8}, // corrected - "hw_version_general_ram": {"az-1": 4}, - "hw_version_general_cores": {"az-1": 2}, + "hw_version_hana_v2_ram": {"az-1": 96, "az-2": 32}, // corrected + "hw_version_hana_v2_cores": {"az-1": 24, "az-2": 8}, // corrected + "hw_version_hana_v2_instances": {"az-1": 2, "az-2": 1}, + "hw_version_general_ram": {"az-1": 4}, + "hw_version_general_cores": {"az-1": 2}, + "hw_version_general_instances": {"az-1": 1}, }, }, }, @@ -561,14 +605,17 @@ func TestIntegration(t *testing.T) { Type: "full_reconcile", ExpectedTotalUsage: map[string]map[string]map[string]int64{ "project-a": { - "hw_version_hana_v2_ram": {"az-1": 96, "az-2": 32}, - "hw_version_hana_v2_cores": {"az-1": 24, "az-2": 8}, - "hw_version_general_ram": {"az-1": 4}, - "hw_version_general_cores": {"az-1": 2}, + "hw_version_hana_v2_ram": {"az-1": 96, "az-2": 32}, + "hw_version_hana_v2_cores": {"az-1": 24, "az-2": 8}, + "hw_version_hana_v2_instances": {"az-1": 2, "az-2": 1}, + "hw_version_general_ram": {"az-1": 4}, + "hw_version_general_cores": {"az-1": 2}, + "hw_version_general_instances": {"az-1": 1}, }, "project-b": { - "hw_version_general_ram": {"az-1": 4}, - "hw_version_general_cores": {"az-1": 2}, + "hw_version_general_ram": {"az-1": 4}, + "hw_version_general_cores": {"az-1": 2}, + "hw_version_general_instances": {"az-1": 1}, }, }, }, @@ -598,10 +645,12 @@ func TestIntegration(t *testing.T) { ), ExpectedTotalUsage: map[string]map[string]map[string]int64{ "project-a": { - "hw_version_hana_v2_ram": {"az-1": 128, "az-2": 32}, - "hw_version_hana_v2_cores": {"az-1": 32, "az-2": 8}, - "hw_version_general_ram": {"az-1": 4}, - "hw_version_general_cores": {"az-1": 2}, + "hw_version_hana_v2_ram": {"az-1": 128, "az-2": 32}, + "hw_version_hana_v2_cores": {"az-1": 32, "az-2": 8}, + "hw_version_hana_v2_instances": {"az-1": 3, "az-2": 1}, + "hw_version_general_ram": {"az-1": 4}, + "hw_version_general_cores": {"az-1": 2}, + "hw_version_general_instances": {"az-1": 1}, }, }, }, @@ -629,8 +678,9 @@ func TestIntegration(t *testing.T) { ), ExpectedTotalUsage: map[string]map[string]map[string]int64{ "project-b": { - "hw_version_general_ram": {"az-1": 8}, // 4+4 drift - "hw_version_general_cores": {"az-1": 4}, // 2+2 drift + "hw_version_general_ram": {"az-1": 8}, // 4+4 drift + "hw_version_general_cores": {"az-1": 4}, // 2+2 drift + "hw_version_general_instances": {"az-1": 2}, // 1+1 drift }, }, }, @@ -663,10 +713,12 @@ func TestIntegration(t *testing.T) { ), ExpectedTotalUsage: map[string]map[string]map[string]int64{ "project-a": { - "hw_version_hana_v2_ram": {"az-1": 96, "az-2": 32}, // 128-32=96 - "hw_version_hana_v2_cores": {"az-1": 24, "az-2": 8}, // 32-8=24 - "hw_version_general_ram": {"az-1": 4}, - "hw_version_general_cores": {"az-1": 2}, + "hw_version_hana_v2_ram": {"az-1": 96, "az-2": 32}, // 128-32=96 + "hw_version_hana_v2_cores": {"az-1": 24, "az-2": 8}, // 32-8=24 + "hw_version_hana_v2_instances": {"az-1": 2, "az-2": 1}, + "hw_version_general_ram": {"az-1": 4}, + "hw_version_general_cores": {"az-1": 2}, + "hw_version_general_instances": {"az-1": 1}, }, }, }, @@ -692,14 +744,17 @@ func TestIntegration(t *testing.T) { }, ExpectedTotalUsage: map[string]map[string]map[string]int64{ "project-a": { - "hw_version_hana_v2_ram": {"az-1": 128, "az-2": 32}, // corrected up - "hw_version_hana_v2_cores": {"az-1": 32, "az-2": 8}, // corrected up - "hw_version_general_ram": {"az-1": 4}, - "hw_version_general_cores": {"az-1": 2}, + "hw_version_hana_v2_ram": {"az-1": 128, "az-2": 32}, // corrected up + "hw_version_hana_v2_cores": {"az-1": 32, "az-2": 8}, // corrected up + "hw_version_hana_v2_instances": {"az-1": 3, "az-2": 1}, + "hw_version_general_ram": {"az-1": 4}, + "hw_version_general_cores": {"az-1": 2}, + "hw_version_general_instances": {"az-1": 1}, }, "project-b": { - "hw_version_general_ram": {"az-1": 4}, // corrected down - "hw_version_general_cores": {"az-1": 2}, // corrected down + "hw_version_general_ram": {"az-1": 4}, // corrected down + "hw_version_general_cores": {"az-1": 2}, // corrected down + "hw_version_general_instances": {"az-1": 1}, }, }, }, @@ -730,10 +785,12 @@ func TestIntegration(t *testing.T) { // vm-1 migrated, NOT decremented -- totals unchanged ExpectedTotalUsage: map[string]map[string]map[string]int64{ "project-a": { - "hw_version_hana_v2_ram": {"az-1": 128, "az-2": 32}, - "hw_version_hana_v2_cores": {"az-1": 32, "az-2": 8}, - "hw_version_general_ram": {"az-1": 4}, - "hw_version_general_cores": {"az-1": 2}, + "hw_version_hana_v2_ram": {"az-1": 128, "az-2": 32}, + "hw_version_hana_v2_cores": {"az-1": 32, "az-2": 8}, + "hw_version_hana_v2_instances": {"az-1": 3, "az-2": 1}, + "hw_version_general_ram": {"az-1": 4}, + "hw_version_general_cores": {"az-1": 2}, + "hw_version_general_instances": {"az-1": 1}, }, }, }, @@ -743,14 +800,17 @@ func TestIntegration(t *testing.T) { Type: "full_reconcile", ExpectedTotalUsage: map[string]map[string]map[string]int64{ "project-a": { - "hw_version_hana_v2_ram": {"az-1": 128, "az-2": 32}, - "hw_version_hana_v2_cores": {"az-1": 32, "az-2": 8}, - "hw_version_general_ram": {"az-1": 4}, - "hw_version_general_cores": {"az-1": 2}, + "hw_version_hana_v2_ram": {"az-1": 128, "az-2": 32}, + "hw_version_hana_v2_cores": {"az-1": 32, "az-2": 8}, + "hw_version_hana_v2_instances": {"az-1": 3, "az-2": 1}, + "hw_version_general_ram": {"az-1": 4}, + "hw_version_general_cores": {"az-1": 2}, + "hw_version_general_instances": {"az-1": 1}, }, "project-b": { - "hw_version_general_ram": {"az-1": 4}, - "hw_version_general_cores": {"az-1": 2}, + "hw_version_general_ram": {"az-1": 4}, + "hw_version_general_cores": {"az-1": 2}, + "hw_version_general_instances": {"az-1": 1}, }, }, }, @@ -770,18 +830,22 @@ func TestIntegration(t *testing.T) { // Only az-1 data should be written (az-2 CRD doesn't exist) ExpectedTotalUsage: map[string]map[string]map[string]int64{ "project-a": { - "hw_version_hana_v2_ram": {"az-1": 96}, - "hw_version_hana_v2_cores": {"az-1": 24}, - "hw_version_general_ram": {"az-1": 4}, - "hw_version_general_cores": {"az-1": 2}, + "hw_version_hana_v2_ram": {"az-1": 96}, + "hw_version_hana_v2_cores": {"az-1": 24}, + "hw_version_hana_v2_instances": {"az-1": 2}, + "hw_version_general_ram": {"az-1": 4}, + "hw_version_general_cores": {"az-1": 2}, + "hw_version_general_instances": {"az-1": 1}, }, }, ExpectedPaygUsage: map[string]map[string]map[string]int64{ "project-a": { - "hw_version_hana_v2_ram": {"az-1": 96}, - "hw_version_hana_v2_cores": {"az-1": 24}, - "hw_version_general_ram": {"az-1": 4}, - "hw_version_general_cores": {"az-1": 2}, + "hw_version_hana_v2_ram": {"az-1": 96}, + "hw_version_hana_v2_cores": {"az-1": 24}, + "hw_version_hana_v2_instances": {"az-1": 2}, + "hw_version_general_ram": {"az-1": 4}, + "hw_version_general_cores": {"az-1": 2}, + "hw_version_general_instances": {"az-1": 1}, }, }, }, @@ -812,14 +876,17 @@ func TestIntegration(t *testing.T) { // Verify TotalUsage is correctly computed from VMs ExpectedTotalUsage: map[string]map[string]map[string]int64{ "project-a": { - "hw_version_hana_v2_ram": {"az-1": 96, "az-2": 32}, - "hw_version_hana_v2_cores": {"az-1": 24, "az-2": 8}, - "hw_version_general_ram": {"az-1": 4}, - "hw_version_general_cores": {"az-1": 2}, + "hw_version_hana_v2_ram": {"az-1": 96, "az-2": 32}, + "hw_version_hana_v2_cores": {"az-1": 24, "az-2": 8}, + "hw_version_hana_v2_instances": {"az-1": 2, "az-2": 1}, + "hw_version_general_ram": {"az-1": 4}, + "hw_version_general_cores": {"az-1": 2}, + "hw_version_general_instances": {"az-1": 1}, }, "project-b": { - "hw_version_general_ram": {"az-1": 4}, - "hw_version_general_cores": {"az-1": 2}, + "hw_version_general_ram": {"az-1": 4}, + "hw_version_general_cores": {"az-1": 2}, + "hw_version_general_instances": {"az-1": 1}, }, }, // Verify PaygUsage = TotalUsage - CRUsage per AZ @@ -827,14 +894,17 @@ func TestIntegration(t *testing.T) { // az-2: hana_v2_ram: 32-3=29, hana_v2_cores: 8-0=8 ExpectedPaygUsage: map[string]map[string]map[string]int64{ "project-a": { - "hw_version_hana_v2_ram": {"az-1": 91, "az-2": 29}, - "hw_version_hana_v2_cores": {"az-1": 20, "az-2": 8}, - "hw_version_general_ram": {"az-1": 4}, - "hw_version_general_cores": {"az-1": 2}, + "hw_version_hana_v2_ram": {"az-1": 91, "az-2": 29}, + "hw_version_hana_v2_cores": {"az-1": 20, "az-2": 8}, + "hw_version_hana_v2_instances": {"az-1": 2, "az-2": 1}, + "hw_version_general_ram": {"az-1": 4}, + "hw_version_general_cores": {"az-1": 2}, + "hw_version_general_instances": {"az-1": 1}, }, "project-b": { - "hw_version_general_ram": {"az-1": 4}, - "hw_version_general_cores": {"az-1": 2}, + "hw_version_general_ram": {"az-1": 4}, + "hw_version_general_cores": {"az-1": 2}, + "hw_version_general_instances": {"az-1": 1}, }, }, }, From b570ae1042031c7e07dae861f537c1198788b75e Mon Sep 17 00:00:00 2001 From: mblos <156897072+mblos@users.noreply.github.com> Date: Mon, 11 May 2026 14:36:42 +0200 Subject: [PATCH 2/7] fix: LIQUID API info and capacity endpoint bugs (#838) --- .../reservations/commitments/api/info.go | 33 ++++- .../reservations/commitments/api/info_test.go | 128 +++++++++++++++++- .../commitments/api/report_capacity_test.go | 45 +++++- 3 files changed, 190 insertions(+), 16 deletions(-) diff --git a/internal/scheduling/reservations/commitments/api/info.go b/internal/scheduling/reservations/commitments/api/info.go index d8459c50b..b1a4fd776 100644 --- a/internal/scheduling/reservations/commitments/api/info.go +++ b/internal/scheduling/reservations/commitments/api/info.go @@ -7,6 +7,7 @@ import ( "context" "encoding/json" "fmt" + "math" "net/http" "strconv" "strings" @@ -75,12 +76,22 @@ func (api *HTTPAPI) recordInfoMetrics(statusCode int, startTime time.Time) { } // resourceAttributes holds the custom attributes for a resource in the info API response. +// Ratio values are in GiB per vCPU, matching the RAM resource unit (UnitGibibytes). type resourceAttributes struct { RamCoreRatio *uint64 `json:"ramCoreRatio,omitempty"` RamCoreRatioMin *uint64 `json:"ramCoreRatioMin,omitempty"` RamCoreRatioMax *uint64 `json:"ramCoreRatioMax,omitempty"` } +// mibToGiB converts a MiB pointer value to GiB, rounded to the nearest integer. Returns nil if v is nil. +func mibToGiB(v *uint64) *uint64 { + if v == nil { + return nil + } + gib := uint64(math.Round(float64(*v) / 1024)) + return &gib +} + // buildServiceInfo constructs the ServiceInfo response with metadata for all flavor groups. // For each flavor group, three resources are registered: // - _ram: RAM resource (unit = multiples of smallest flavor RAM, HandlesCommitments=true only if fixed ratio) @@ -110,11 +121,13 @@ func (api *HTTPAPI) buildServiceInfo(ctx context.Context, logger logr.Logger) (l } flavorListStr := strings.Join(flavorNames, ", ") - // Build attributes JSON with ratio info (shared across all resource types) + // Build attributes JSON with ratio info (shared across all resource types). + // Ratios are stored in MiB/vCPU in the knowledge CRD; convert to GiB/vCPU here + // so the values match the GiB unit used by the RAM resource. attrs := resourceAttributes{ - RamCoreRatio: groupData.RamCoreRatio, - RamCoreRatioMin: groupData.RamCoreRatioMin, - RamCoreRatioMax: groupData.RamCoreRatioMax, + RamCoreRatio: mibToGiB(groupData.RamCoreRatio), + RamCoreRatioMin: mibToGiB(groupData.RamCoreRatioMin), + RamCoreRatioMax: mibToGiB(groupData.RamCoreRatioMax), } attrsJSON, err := json.Marshal(attrs) if err != nil { @@ -146,13 +159,17 @@ func (api *HTTPAPI) buildServiceInfo(ctx context.Context, logger logr.Logger) (l // === 2. Cores Resource === coresResourceName := liquid.ResourceName(commitments.ResourceNameCores(groupName)) + coresTopology := liquid.AZAwareTopology + if resCfg.Cores.HandlesCommitments { + coresTopology = liquid.AZSeparatedTopology + } resources[coresResourceName] = liquid.ResourceInfo{ DisplayName: fmt.Sprintf( "CPU cores (usable by: %s)", flavorListStr, ), Unit: liquid.UnitNone, - Topology: liquid.AZAwareTopology, + Topology: coresTopology, NeedsResourceDemand: false, HasCapacity: resCfg.Cores.HasCapacity, HasQuota: resCfg.Cores.HasQuota, @@ -162,13 +179,17 @@ func (api *HTTPAPI) buildServiceInfo(ctx context.Context, logger logr.Logger) (l // === 3. Instances Resource === instancesResourceName := liquid.ResourceName(commitments.ResourceNameInstances(groupName)) + instancesTopology := liquid.AZAwareTopology + if resCfg.Instances.HandlesCommitments { + instancesTopology = liquid.AZSeparatedTopology + } resources[instancesResourceName] = liquid.ResourceInfo{ DisplayName: fmt.Sprintf( "instances (usable by: %s)", flavorListStr, ), Unit: liquid.UnitNone, - Topology: liquid.AZAwareTopology, + Topology: instancesTopology, NeedsResourceDemand: false, HasCapacity: resCfg.Instances.HasCapacity, HasQuota: resCfg.Instances.HasQuota, diff --git a/internal/scheduling/reservations/commitments/api/info_test.go b/internal/scheduling/reservations/commitments/api/info_test.go index e74964925..c7d946ddc 100644 --- a/internal/scheduling/reservations/commitments/api/info_test.go +++ b/internal/scheduling/reservations/commitments/api/info_test.go @@ -138,8 +138,6 @@ func TestHandleInfo_InvalidFlavorMemory(t *testing.T) { } func TestHandleInfo_ResourceFlagsFromConfig(t *testing.T) { - // Test that resource flags (HandlesCommitments, HasCapacity, HasQuota) are read from config, - // not derived from flavor group metadata. Both groups get resources regardless of ratio. scheme := runtime.NewScheme() if err := v1alpha1.AddToScheme(scheme); err != nil { t.Fatalf("failed to add scheme: %v", err) @@ -155,7 +153,9 @@ func TestHandleInfo_ResourceFlagsFromConfig(t *testing.T) { }, "largestFlavor": map[string]interface{}{"name": "hana_c8_m32", "vcpus": 8, "memoryMB": 32768, "diskGB": 100}, "smallestFlavor": map[string]interface{}{"name": "hana_c4_m16", "vcpus": 4, "memoryMB": 16384, "diskGB": 50}, - "ramCoreRatio": 4096, + // 4094 MiB/vCPU simulates real flavor RAM (4096 MiB nominal − 2 MiB video RAM). + // Truncating division gives 3; rounding gives 4. + "ramCoreRatio": 4094, }, { "name": "v2_variable", @@ -247,7 +247,7 @@ func TestHandleInfo_ResourceFlagsFromConfig(t *testing.T) { t.Error("hw_version_hana_fixed_ram: expected HasQuota=true (fixed ratio groups accept quotas)") } - // Test Cores resource: hw_version_hana_fixed_cores (always AZAwareTopology, no quota) + // Test Cores resource: hw_version_hana_fixed_cores (HandlesCommitments=false → AZAwareTopology) coresResource, ok := serviceInfo.Resources["hw_version_hana_fixed_cores"] if !ok { t.Fatal("expected hw_version_hana_fixed_cores resource to exist") @@ -265,7 +265,7 @@ func TestHandleInfo_ResourceFlagsFromConfig(t *testing.T) { t.Error("hw_version_hana_fixed_cores: expected HasQuota=false") } - // Test Instances resource: hw_version_hana_fixed_instances (always AZAwareTopology, no quota) + // Test Instances resource: hw_version_hana_fixed_instances (HandlesCommitments=false → AZAwareTopology) instancesResource, ok := serviceInfo.Resources["hw_version_hana_fixed_instances"] if !ok { t.Fatal("expected hw_version_hana_fixed_instances resource to exist") @@ -334,4 +334,122 @@ func TestHandleInfo_ResourceFlagsFromConfig(t *testing.T) { if v2InstancesResource.HasQuota { t.Error("hw_version_v2_variable_instances: expected HasQuota=false") } + + // Verify ratio attributes are converted from MiB to GiB. + // hana_fixed has ramCoreRatio=4096 MiB/vCPU → expect 4 GiB/vCPU. + checkAttrsRatio(t, "hw_version_hana_fixed_ram", ramResource.Attributes, 4, nil, nil) + // v2_variable has ramCoreRatioMin=2048 MiB/vCPU, ramCoreRatioMax=16384 MiB/vCPU → expect 2, 16 GiB/vCPU. + checkAttrsRatio(t, "hw_version_v2_variable_ram", v2RamResource.Attributes, 0, ptr(uint64(2)), ptr(uint64(16))) +} + +func TestHandleInfo_TopologyFollowsHandlesCommitments(t *testing.T) { + // Verifies that any resource with HandlesCommitments=true gets AZSeparatedTopology, + // regardless of whether it's RAM, Cores, or Instances. + scheme := runtime.NewScheme() + if err := v1alpha1.AddToScheme(scheme); err != nil { + t.Fatalf("failed to add scheme: %v", err) + } + + features := []map[string]interface{}{ + { + "name": "fg", + "flavors": []map[string]interface{}{{"name": "fg_c4_m16", "vcpus": 4, "memoryMB": 16384, "diskGB": 50}}, + "largestFlavor": map[string]interface{}{"name": "fg_c4_m16", "vcpus": 4, "memoryMB": 16384, "diskGB": 50}, + "smallestFlavor": map[string]interface{}{"name": "fg_c4_m16", "vcpus": 4, "memoryMB": 16384, "diskGB": 50}, + "ramCoreRatio": 4096, + }, + } + raw, err := v1alpha1.BoxFeatureList(features) + if err != nil { + t.Fatalf("failed to box features: %v", err) + } + knowledge := &v1alpha1.Knowledge{ + ObjectMeta: v1.ObjectMeta{Name: "flavor-groups"}, + Spec: v1alpha1.KnowledgeSpec{ + SchedulingDomain: v1alpha1.SchedulingDomainNova, + Extractor: v1alpha1.KnowledgeExtractorSpec{Name: "flavor_groups"}, + }, + Status: v1alpha1.KnowledgeStatus{ + Conditions: []v1.Condition{{Type: v1alpha1.KnowledgeConditionReady, Status: "True"}}, + Raw: raw, + LastContentChange: v1.Now(), + }, + } + + // All three resource types handle commitments. + cfg := commitments.DefaultAPIConfig() + cfg.FlavorGroupResourceConfig = map[string]commitments.FlavorGroupResourcesConfig{ + "fg": { + RAM: commitments.ResourceTypeConfig{HandlesCommitments: true, HasCapacity: true, HasQuota: true}, + Cores: commitments.ResourceTypeConfig{HandlesCommitments: true, HasCapacity: true, HasQuota: true}, + Instances: commitments.ResourceTypeConfig{HandlesCommitments: true, HasCapacity: true, HasQuota: true}, + }, + } + + k8sClient := fake.NewClientBuilder().WithScheme(scheme).WithObjects(knowledge).Build() + api := NewAPIWithConfig(k8sClient, cfg, nil) + + req := httptest.NewRequest(http.MethodGet, "/commitments/v1/info", http.NoBody) + w := httptest.NewRecorder() + api.HandleInfo(w, req) + + resp := w.Result() + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + t.Fatalf("expected 200, got %d", resp.StatusCode) + } + + var serviceInfo liquid.ServiceInfo + if err := json.NewDecoder(resp.Body).Decode(&serviceInfo); err != nil { + t.Fatalf("failed to decode: %v", err) + } + + for _, resName := range []string{"hw_version_fg_ram", "hw_version_fg_cores", "hw_version_fg_instances"} { + res, ok := serviceInfo.Resources[liquid.ResourceName(resName)] + if !ok { + t.Fatalf("expected resource %s", resName) + } + if res.Topology != liquid.AZSeparatedTopology { + t.Errorf("%s: expected Topology=%q (HandlesCommitments=true), got %q", resName, liquid.AZSeparatedTopology, res.Topology) + } + } +} + +// ptr returns a pointer to v, for use in test assertions. +func ptr[T any](v T) *T { return &v } + +// checkAttrsRatio decodes the Attributes JSON of a resource and verifies the ratio fields +// are in GiB/vCPU. Pass ratioGiB=0 to skip the fixed-ratio check; pass nil min/max to skip range checks. +func checkAttrsRatio(t *testing.T, resName string, raw json.RawMessage, ratioGiB uint64, minGiB, maxGiB *uint64) { + t.Helper() + var attrs struct { + RamCoreRatio *uint64 `json:"ramCoreRatio"` + RamCoreRatioMin *uint64 `json:"ramCoreRatioMin"` + RamCoreRatioMax *uint64 `json:"ramCoreRatioMax"` + } + if err := json.Unmarshal(raw, &attrs); err != nil { + t.Fatalf("%s: failed to decode attributes: %v", resName, err) + } + if ratioGiB != 0 { + if attrs.RamCoreRatio == nil { + t.Errorf("%s: expected ramCoreRatio=%d GiB/vCPU, got nil", resName, ratioGiB) + } else if *attrs.RamCoreRatio != ratioGiB { + t.Errorf("%s: expected ramCoreRatio=%d GiB/vCPU, got %d", resName, ratioGiB, *attrs.RamCoreRatio) + } + } + if minGiB != nil { + if attrs.RamCoreRatioMin == nil { + t.Errorf("%s: expected ramCoreRatioMin=%d GiB/vCPU, got nil", resName, *minGiB) + } else if *attrs.RamCoreRatioMin != *minGiB { + t.Errorf("%s: expected ramCoreRatioMin=%d GiB/vCPU, got %d", resName, *minGiB, *attrs.RamCoreRatioMin) + } + } + if maxGiB != nil { + if attrs.RamCoreRatioMax == nil { + t.Errorf("%s: expected ramCoreRatioMax=%d GiB/vCPU, got nil", resName, *maxGiB) + } else if *attrs.RamCoreRatioMax != *maxGiB { + t.Errorf("%s: expected ramCoreRatioMax=%d GiB/vCPU, got %d", resName, *maxGiB, *attrs.RamCoreRatioMax) + } + } } diff --git a/internal/scheduling/reservations/commitments/api/report_capacity_test.go b/internal/scheduling/reservations/commitments/api/report_capacity_test.go index 638c060db..362f3000f 100644 --- a/internal/scheduling/reservations/commitments/api/report_capacity_test.go +++ b/internal/scheduling/reservations/commitments/api/report_capacity_test.go @@ -285,7 +285,7 @@ func TestCapacityCalculator(t *testing.T) { t.Run("CalculateCapacity reads capacity and usage from Ready CRD", func(t *testing.T) { knowledge := createTestFlavorGroupKnowledge(t) - crd := createTestFlavorGroupCapacity("test-group", "az-one", "test_c8_m32", 1000, 800, true) + crd := createTestFlavorGroupCapacity(1000, 800, true) fakeClient := fake.NewClientBuilder(). WithScheme(scheme). WithObjects(knowledge, crd). @@ -322,7 +322,7 @@ func TestCapacityCalculator(t *testing.T) { t.Run("CalculateCapacity returns zero capacity for missing CRD", func(t *testing.T) { knowledge := createTestFlavorGroupKnowledge(t) // CRD exists only for az-one; az-two has no CRD - crd := createTestFlavorGroupCapacity("test-group", "az-one", "test_c8_m32", 500, 400, true) + crd := createTestFlavorGroupCapacity(500, 400, true) fakeClient := fake.NewClientBuilder(). WithScheme(scheme). WithObjects(knowledge, crd). @@ -351,7 +351,7 @@ func TestCapacityCalculator(t *testing.T) { t.Run("CalculateCapacity omits usage for stale CRD (Ready=False)", func(t *testing.T) { knowledge := createTestFlavorGroupKnowledge(t) - crd := createTestFlavorGroupCapacity("test-group", "az-one", "test_c8_m32", 1000, 800, false) + crd := createTestFlavorGroupCapacity(1000, 800, false) fakeClient := fake.NewClientBuilder(). WithScheme(scheme). WithObjects(knowledge, crd). @@ -382,9 +382,41 @@ func TestCapacityCalculator(t *testing.T) { t.Error("expected usage to be absent (None) for stale CRD") } }) + + t.Run("CalculateCapacity omits resources with HasCapacity=false", func(t *testing.T) { + knowledge := createTestFlavorGroupKnowledge(t) + crd := createTestFlavorGroupCapacity(100, 80, true) + fakeClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(knowledge, crd). + WithStatusSubresource(crd). + Build() + + // Only RAM and Cores have capacity; Instances does not. + cfgNoInstances := commitments.APIConfig{ + FlavorGroupResourceConfig: map[string]commitments.FlavorGroupResourcesConfig{ + "*": { + RAM: commitments.ResourceTypeConfig{HasCapacity: true}, + Cores: commitments.ResourceTypeConfig{HasCapacity: true}, + Instances: commitments.ResourceTypeConfig{HasCapacity: false}, + }, + }, + } + calculator := commitments.NewCapacityCalculator(fakeClient, cfgNoInstances) + req := liquid.ServiceCapacityRequest{AllAZs: []liquid.AvailabilityZone{"az-one"}} + report, err := calculator.CalculateCapacity(context.Background(), req) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(report.Resources) != 2 { + t.Fatalf("expected 2 resources (ram, cores), got %d: %v", len(report.Resources), report.Resources) + } + if _, ok := report.Resources["hw_version_test-group_instances"]; ok { + t.Error("expected hw_version_test-group_instances to be absent (HasCapacity=false)") + } + }) } -// verifyPerAZMatchesRequest checks that perAZ entries match exactly the requested AZs. // This follows the same semantics as nova liquid: the response must contain // entries for all AZs in AllAZs, no more and no less. func verifyPerAZMatchesRequest(t *testing.T, res *liquid.ResourceCapacityReport, requestedAZs []liquid.AvailabilityZone) { @@ -443,7 +475,10 @@ func createEmptyFlavorGroupKnowledge() *v1alpha1.Knowledge { // createTestFlavorGroupCapacity creates a FlavorGroupCapacity CRD for testing. // totalSlots and placeableSlots are for the named smallest flavor entry. // ready controls whether the Ready condition is True or False. -func createTestFlavorGroupCapacity(group, az, smallestFlavorName string, totalSlots, placeableSlots int64, ready bool) *v1alpha1.FlavorGroupCapacity { +func createTestFlavorGroupCapacity(totalSlots, placeableSlots int64, ready bool) *v1alpha1.FlavorGroupCapacity { + const group = "test-group" + const az = "az-one" + const smallestFlavorName = "test_c8_m32" conditionStatus := v1.ConditionTrue if !ready { conditionStatus = v1.ConditionFalse From 405ac0067679a2ad401c049ac5c684fdfb774ead Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 11 May 2026 12:45:50 +0000 Subject: [PATCH 3/7] Bump cortex chart appVersions to sha-b570ae10 [skip ci] --- helm/library/cortex/Chart.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/helm/library/cortex/Chart.yaml b/helm/library/cortex/Chart.yaml index f82fe0576..91e6c0ef7 100644 --- a/helm/library/cortex/Chart.yaml +++ b/helm/library/cortex/Chart.yaml @@ -3,6 +3,6 @@ name: cortex description: A Helm chart to distribute cortex. type: application version: 0.0.48 -appVersion: "sha-f3c2ce54" +appVersion: "sha-b570ae10" icon: "https://example.com/icon.png" dependencies: [] From d72041b191dfe39cf34bcbd986d5fb2413abba58 Mon Sep 17 00:00:00 2001 From: Malte Viering Date: Mon, 11 May 2026 12:38:35 +0000 Subject: [PATCH 4/7] Bump core to 0.0.49 and bundles to 0.0.62 * feat(quota): track instance count (VM count) per project/AZ (#837) * fix: LIQUID API info and capacity endpoint bugs (#838) --- helm/bundles/cortex-cinder/Chart.yaml | 6 +++--- helm/bundles/cortex-crds/Chart.yaml | 4 ++-- helm/bundles/cortex-ironcore/Chart.yaml | 4 ++-- helm/bundles/cortex-manila/Chart.yaml | 6 +++--- helm/bundles/cortex-nova/Chart.yaml | 6 +++--- helm/bundles/cortex-pods/Chart.yaml | 4 ++-- helm/library/cortex/Chart.yaml | 2 +- 7 files changed, 16 insertions(+), 16 deletions(-) diff --git a/helm/bundles/cortex-cinder/Chart.yaml b/helm/bundles/cortex-cinder/Chart.yaml index c859282c8..9bc6899c8 100644 --- a/helm/bundles/cortex-cinder/Chart.yaml +++ b/helm/bundles/cortex-cinder/Chart.yaml @@ -5,7 +5,7 @@ apiVersion: v2 name: cortex-cinder description: A Helm chart deploying Cortex for Cinder. type: application -version: 0.0.61 +version: 0.0.62 appVersion: 0.1.0 dependencies: # from: file://../../library/cortex-postgres @@ -16,12 +16,12 @@ dependencies: # from: file://../../library/cortex - name: cortex repository: oci://ghcr.io/cobaltcore-dev/cortex/charts - version: 0.0.48 + version: 0.0.49 alias: cortex-knowledge-controllers # from: file://../../library/cortex - name: cortex repository: oci://ghcr.io/cobaltcore-dev/cortex/charts - version: 0.0.48 + version: 0.0.49 alias: cortex-scheduling-controllers # Owner info adds a configmap to the kubernetes cluster with information on diff --git a/helm/bundles/cortex-crds/Chart.yaml b/helm/bundles/cortex-crds/Chart.yaml index 7a4672b1f..f97000078 100644 --- a/helm/bundles/cortex-crds/Chart.yaml +++ b/helm/bundles/cortex-crds/Chart.yaml @@ -5,13 +5,13 @@ apiVersion: v2 name: cortex-crds description: A Helm chart deploying Cortex CRDs. type: application -version: 0.0.61 +version: 0.0.62 appVersion: 0.1.0 dependencies: # from: file://../../library/cortex - name: cortex repository: oci://ghcr.io/cobaltcore-dev/cortex/charts - version: 0.0.48 + version: 0.0.49 # Owner info adds a configmap to the kubernetes cluster with information on # the service owner. This makes it easier to find out who to contact in case diff --git a/helm/bundles/cortex-ironcore/Chart.yaml b/helm/bundles/cortex-ironcore/Chart.yaml index cbb2574ea..7e6918997 100644 --- a/helm/bundles/cortex-ironcore/Chart.yaml +++ b/helm/bundles/cortex-ironcore/Chart.yaml @@ -5,13 +5,13 @@ apiVersion: v2 name: cortex-ironcore description: A Helm chart deploying Cortex for IronCore. type: application -version: 0.0.61 +version: 0.0.62 appVersion: 0.1.0 dependencies: # from: file://../../library/cortex - name: cortex repository: oci://ghcr.io/cobaltcore-dev/cortex/charts - version: 0.0.48 + version: 0.0.49 # Owner info adds a configmap to the kubernetes cluster with information on # the service owner. This makes it easier to find out who to contact in case diff --git a/helm/bundles/cortex-manila/Chart.yaml b/helm/bundles/cortex-manila/Chart.yaml index 849523e20..bac6774b7 100644 --- a/helm/bundles/cortex-manila/Chart.yaml +++ b/helm/bundles/cortex-manila/Chart.yaml @@ -5,7 +5,7 @@ apiVersion: v2 name: cortex-manila description: A Helm chart deploying Cortex for Manila. type: application -version: 0.0.61 +version: 0.0.62 appVersion: 0.1.0 dependencies: # from: file://../../library/cortex-postgres @@ -16,12 +16,12 @@ dependencies: # from: file://../../library/cortex - name: cortex repository: oci://ghcr.io/cobaltcore-dev/cortex/charts - version: 0.0.48 + version: 0.0.49 alias: cortex-knowledge-controllers # from: file://../../library/cortex - name: cortex repository: oci://ghcr.io/cobaltcore-dev/cortex/charts - version: 0.0.48 + version: 0.0.49 alias: cortex-scheduling-controllers # Owner info adds a configmap to the kubernetes cluster with information on diff --git a/helm/bundles/cortex-nova/Chart.yaml b/helm/bundles/cortex-nova/Chart.yaml index ebf4fdba9..9ea033123 100644 --- a/helm/bundles/cortex-nova/Chart.yaml +++ b/helm/bundles/cortex-nova/Chart.yaml @@ -5,7 +5,7 @@ apiVersion: v2 name: cortex-nova description: A Helm chart deploying Cortex for Nova. type: application -version: 0.0.61 +version: 0.0.62 appVersion: 0.1.0 dependencies: # from: file://../../library/cortex-postgres @@ -16,12 +16,12 @@ dependencies: # from: file://../../library/cortex - name: cortex repository: oci://ghcr.io/cobaltcore-dev/cortex/charts - version: 0.0.48 + version: 0.0.49 alias: cortex-knowledge-controllers # from: file://../../library/cortex - name: cortex repository: oci://ghcr.io/cobaltcore-dev/cortex/charts - version: 0.0.48 + version: 0.0.49 alias: cortex-scheduling-controllers # Owner info adds a configmap to the kubernetes cluster with information on diff --git a/helm/bundles/cortex-pods/Chart.yaml b/helm/bundles/cortex-pods/Chart.yaml index 5c998ec5b..5b62b5682 100644 --- a/helm/bundles/cortex-pods/Chart.yaml +++ b/helm/bundles/cortex-pods/Chart.yaml @@ -5,13 +5,13 @@ apiVersion: v2 name: cortex-pods description: A Helm chart deploying Cortex for Pods. type: application -version: 0.0.61 +version: 0.0.62 appVersion: 0.1.0 dependencies: # from: file://../../library/cortex - name: cortex repository: oci://ghcr.io/cobaltcore-dev/cortex/charts - version: 0.0.48 + version: 0.0.49 # Owner info adds a configmap to the kubernetes cluster with information on # the service owner. This makes it easier to find out who to contact in case diff --git a/helm/library/cortex/Chart.yaml b/helm/library/cortex/Chart.yaml index 91e6c0ef7..bb72edffe 100644 --- a/helm/library/cortex/Chart.yaml +++ b/helm/library/cortex/Chart.yaml @@ -2,7 +2,7 @@ apiVersion: v2 name: cortex description: A Helm chart to distribute cortex. type: application -version: 0.0.48 +version: 0.0.49 appVersion: "sha-b570ae10" icon: "https://example.com/icon.png" dependencies: [] From 4d24fd1ba627ab74c49d3af88d49d45d50054024 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 11 May 2026 12:58:14 +0000 Subject: [PATCH 5/7] Bump cortex chart appVersions to sha-d72041b1 [skip ci] --- helm/library/cortex/Chart.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/helm/library/cortex/Chart.yaml b/helm/library/cortex/Chart.yaml index bb72edffe..0c87ca43c 100644 --- a/helm/library/cortex/Chart.yaml +++ b/helm/library/cortex/Chart.yaml @@ -3,6 +3,6 @@ name: cortex description: A Helm chart to distribute cortex. type: application version: 0.0.49 -appVersion: "sha-b570ae10" +appVersion: "sha-d72041b1" icon: "https://example.com/icon.png" dependencies: [] From 7fda95698b5dad11070869f48f48d287096ef0d6 Mon Sep 17 00:00:00 2001 From: Malte Viering Date: Mon, 11 May 2026 16:08:05 +0200 Subject: [PATCH 6/7] fix: check localhost for hanging e2e test --- internal/scheduling/cinder/e2e_checks.go | 2 +- internal/scheduling/manila/e2e_checks.go | 2 +- internal/scheduling/nova/e2e_checks.go | 2 +- internal/scheduling/reservations/commitments/e2e_checks.go | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/internal/scheduling/cinder/e2e_checks.go b/internal/scheduling/cinder/e2e_checks.go index 511668c24..6b6c6715e 100644 --- a/internal/scheduling/cinder/e2e_checks.go +++ b/internal/scheduling/cinder/e2e_checks.go @@ -35,7 +35,7 @@ func checkCinderSchedulerReturnsValidHosts( Hosts: []api.ExternalSchedulerHost{}, Weights: map[string]float64{}, } - apiURL := "http://cortex-cinder-scheduler:8080/scheduler/cinder/external" + apiURL := "http://localhost:8080/scheduler/cinder/external" slog.Info("sending request to external scheduler", "apiURL", apiURL) requestBody := must.Return(json.Marshal(request)) diff --git a/internal/scheduling/manila/e2e_checks.go b/internal/scheduling/manila/e2e_checks.go index 466535b0e..a14d084c2 100644 --- a/internal/scheduling/manila/e2e_checks.go +++ b/internal/scheduling/manila/e2e_checks.go @@ -82,7 +82,7 @@ func checkManilaSchedulerReturnsValidHosts( Hosts: hosts, Weights: weights, } - apiURL := "http://cortex-manila-scheduler:8080/scheduler/manila/external" + apiURL := "http://localhost:8080/scheduler/manila/external" slog.Info("sending request to external scheduler", "apiURL", apiURL) requestBody := must.Return(json.Marshal(request)) diff --git a/internal/scheduling/nova/e2e_checks.go b/internal/scheduling/nova/e2e_checks.go index a8caca8ba..ad65a57b5 100644 --- a/internal/scheduling/nova/e2e_checks.go +++ b/internal/scheduling/nova/e2e_checks.go @@ -332,7 +332,7 @@ func checkNovaSchedulerReturnsValidHosts( req api.ExternalSchedulerRequest, ) []string { - apiURL := "http://cortex-nova-scheduler:8080/scheduler/nova/external" + apiURL := "http://localhost:8080/scheduler/nova/external" slog.Info("sending request to external scheduler", "apiURL", apiURL) requestBody := must.Return(json.Marshal(req)) diff --git a/internal/scheduling/reservations/commitments/e2e_checks.go b/internal/scheduling/reservations/commitments/e2e_checks.go index 758f5cf24..4895a8043 100644 --- a/internal/scheduling/reservations/commitments/e2e_checks.go +++ b/internal/scheduling/reservations/commitments/e2e_checks.go @@ -22,7 +22,7 @@ import ( const ( // Default URL for the commitments API endpoint. // This should match the service name in the helm chart. - defaultCommitmentsAPIURL = "http://cortex-nova-scheduler:8080" + defaultCommitmentsAPIURL = "http://localhost:8080" // defaultE2EProjectUUID is a well-known fake project UUID used when no ProjectID is configured. // It is intentionally not a real OpenStack project — commitments created under it self-expire. From e57cff6ce175970e2befe6f8e5c062d59f7b6af7 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 11 May 2026 14:18:31 +0000 Subject: [PATCH 7/7] Bump cortex chart appVersions to sha-7fda9569 [skip ci] --- helm/library/cortex/Chart.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/helm/library/cortex/Chart.yaml b/helm/library/cortex/Chart.yaml index 0c87ca43c..b28f3c330 100644 --- a/helm/library/cortex/Chart.yaml +++ b/helm/library/cortex/Chart.yaml @@ -3,6 +3,6 @@ name: cortex description: A Helm chart to distribute cortex. type: application version: 0.0.49 -appVersion: "sha-d72041b1" +appVersion: "sha-7fda9569" icon: "https://example.com/icon.png" dependencies: []