From cb571384a02337387f02b5357f695429d9f08f4f Mon Sep 17 00:00:00 2001 From: Malte Viering Date: Mon, 18 May 2026 10:38:56 +0200 Subject: [PATCH 1/4] feat: multicluster supported az query --- .../reservations/commitments/api/quota.go | 25 +++ pkg/multicluster/client.go | 61 ++++++- pkg/multicluster/client_test.go | 152 ++++++++++++++++++ 3 files changed, 237 insertions(+), 1 deletion(-) diff --git a/internal/scheduling/reservations/commitments/api/quota.go b/internal/scheduling/reservations/commitments/api/quota.go index 167443e36..907433b34 100644 --- a/internal/scheduling/reservations/commitments/api/quota.go +++ b/internal/scheduling/reservations/commitments/api/quota.go @@ -14,6 +14,7 @@ import ( "github.com/cobaltcore-dev/cortex/api/v1alpha1" "github.com/cobaltcore-dev/cortex/internal/scheduling/reservations" commitments "github.com/cobaltcore-dev/cortex/internal/scheduling/reservations/commitments" + "github.com/cobaltcore-dev/cortex/pkg/multicluster" "github.com/google/uuid" "github.com/sapcc/go-api-declarations/liquid" apierrors "k8s.io/apimachinery/pkg/api/errors" @@ -138,6 +139,19 @@ func (api *HTTPAPI) HandleQuota(w http.ResponseWriter, r *http.Request) { ctx := r.Context() + // Pre-filter: skip AZs that have no configured cluster. In multi-AZ setups + // Limes sends quota for all AZs but cortex only manages a subset (KVM AZs). + if mc, ok := api.client.(*multicluster.Client); ok { + if servedAZs := mc.ServedAZs(projectQuotaGVK); servedAZs != nil { + for az := range quotaByAZ { + if !servedAZs[az] { + log.V(1).Info("skipping quota for unserved AZ (no cluster configured)", "az", az, "projectID", projectID) + delete(quotaByAZ, az) + } + } + } + } + // Create or update one ProjectQuota CRD per AZ with retry-on-conflict to handle // concurrent status updates from the quota controller. activeAZs := make(map[string]bool, len(quotaByAZ)) @@ -199,6 +213,14 @@ func (api *HTTPAPI) HandleQuota(w http.ResponseWriter, r *http.Request) { return nil }) if err != nil { + // If no cluster is configured for this AZ, skip it gracefully. + // This happens in multi-AZ setups where quota info is received for + // AZs that cortex does not manage (e.g. no KVM in that AZ). + if multicluster.IsNoClusterMatchedError(err) { + log.V(1).Info("skipping ProjectQuota for unserved AZ", "name", crdName, "az", az) + activeAZs[az] = false + continue + } log.Error(err, "failed to create/update ProjectQuota", "name", crdName, "az", az) api.quotaError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to persist quota for AZ %s: %v", az, err), startTime) return @@ -239,3 +261,6 @@ func (api *HTTPAPI) recordQuotaMetrics(statusCode int, startTime time.Time) { api.quotaMonitor.requestCounter.WithLabelValues(statusCodeStr).Inc() api.quotaMonitor.requestDuration.WithLabelValues(statusCodeStr).Observe(duration) } + +// projectQuotaGVK is the GVK used by the multicluster client for ProjectQuota routing. +var projectQuotaGVK = schema.GroupVersionKind{Group: "cortex.cloud", Version: "v1alpha1", Kind: "ProjectQuota"} diff --git a/pkg/multicluster/client.go b/pkg/multicluster/client.go index 195e9d4d2..331e22acf 100644 --- a/pkg/multicluster/client.go +++ b/pkg/multicluster/client.go @@ -232,7 +232,7 @@ func (c *Client) clusterForWrite(gvk schema.GroupVersionKind, obj any) (cluster. if c.homeGVKs[gvk] { return c.HomeCluster, nil } - return nil, fmt.Errorf("no cluster matched for GVK %s", gvk) + return nil, &NoClusterMatchedError{GVK: gvk} } type duplicateError struct{ msg string } @@ -248,6 +248,65 @@ func IsDuplicateError(err error) bool { return errors.As(err, &de) } +// NoClusterMatchedError is returned when a write operation cannot find a +// matching remote cluster for the given GVK and resource. This typically +// happens in multi-AZ setups where the resource targets an AZ that has no +// configured cluster. +type NoClusterMatchedError struct { + GVK schema.GroupVersionKind +} + +func (e *NoClusterMatchedError) Error() string { + return fmt.Sprintf("no cluster matched for GVK %s", e.GVK) +} + +// IsNoClusterMatchedError returns true if the error indicates that no +// configured cluster matched the resource for a write operation. Callers +// can use this to skip resources targeting unavailable AZs gracefully. +func IsNoClusterMatchedError(err error) bool { + var nce *NoClusterMatchedError + return errors.As(err, &nce) +} + +// ConfiguredRouteLabels returns the routing label sets of all configured +// remote clusters for the given GVK. This can be used to determine which +// availability zones (or other routing dimensions) are served. +// Returns nil if the GVK is only configured for the home cluster. +func (c *Client) ConfiguredRouteLabels(gvk schema.GroupVersionKind) []map[string]string { + c.remoteClustersMu.RLock() + defer c.remoteClustersMu.RUnlock() + remotes := c.remoteClusters[gvk] + if len(remotes) == 0 { + return nil + } + labels := make([]map[string]string, 0, len(remotes)) + for _, r := range remotes { + labels = append(labels, r.labels) + } + return labels +} + +// ServedAZs returns the set of availability zones that have a configured +// remote cluster for the given GVK. It extracts the "availabilityZone" key +// from each remote cluster's routing labels. Returns nil if no remote clusters +// are configured or none have an "availabilityZone" label. +func (c *Client) ServedAZs(gvk schema.GroupVersionKind) map[string]bool { + labelSets := c.ConfiguredRouteLabels(gvk) + if len(labelSets) == 0 { + return nil + } + served := make(map[string]bool, len(labelSets)) + for _, labels := range labelSets { + if az, ok := labels["availabilityZone"]; ok { + served[az] = true + } + } + if len(served) == 0 { + return nil + } + return served +} + // Get iterates over all clusters with the GVK and returns the result. // // If the requested resource is encountered in multiple clusters, this function diff --git a/pkg/multicluster/client_test.go b/pkg/multicluster/client_test.go index 4e2dde5d9..fdbb6779c 100644 --- a/pkg/multicluster/client_test.go +++ b/pkg/multicluster/client_test.go @@ -491,6 +491,9 @@ func TestClient_clusterForWrite_NoMatch(t *testing.T) { if err == nil { t.Error("expected error when no remote cluster matches") } + if !IsNoClusterMatchedError(err) { + t.Errorf("expected IsNoClusterMatchedError to return true, got false for error: %v", err) + } } func TestClient_clusterForWrite_NoRouterMultipleClusters(t *testing.T) { @@ -1624,3 +1627,152 @@ func TestClient_InitFromConf_GVKFormatting(t *testing.T) { }) } } + +func TestIsNoClusterMatchedError(t *testing.T) { + tests := []struct { + name string + err error + expected bool + }{ + { + name: "nil error", + err: nil, + expected: false, + }, + { + name: "no cluster matched error", + err: &NoClusterMatchedError{GVK: configMapGVK}, + expected: true, + }, + { + name: "unrelated error", + err: errors.New("something went wrong"), + expected: false, + }, + { + name: "not found error", + err: apierrors.NewNotFound(schema.GroupResource{Group: "", Resource: "ConfigMap"}, "foo"), + expected: false, + }, + { + name: "duplicate error", + err: &duplicateError{msg: "duplicate"}, + expected: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := IsNoClusterMatchedError(tt.err); got != tt.expected { + t.Errorf("IsNoClusterMatchedError(%v) = %v, want %v", tt.err, got, tt.expected) + } + }) + } +} + +func TestClient_ConfiguredRouteLabels(t *testing.T) { + scheme := newTestScheme(t) + + t.Run("returns nil for home-only GVK", func(t *testing.T) { + c := &Client{ + HomeCluster: newFakeCluster(scheme), + HomeScheme: scheme, + homeGVKs: map[schema.GroupVersionKind]bool{configMapGVK: true}, + } + labels := c.ConfiguredRouteLabels(configMapGVK) + if labels != nil { + t.Errorf("expected nil, got %v", labels) + } + }) + + t.Run("returns nil for unknown GVK", func(t *testing.T) { + c := &Client{ + HomeCluster: newFakeCluster(scheme), + HomeScheme: scheme, + } + labels := c.ConfiguredRouteLabels(configMapGVK) + if labels != nil { + t.Errorf("expected nil, got %v", labels) + } + }) + + t.Run("returns labels for remote clusters", func(t *testing.T) { + c := &Client{ + HomeCluster: newFakeCluster(scheme), + HomeScheme: scheme, + remoteClusters: map[schema.GroupVersionKind][]remoteCluster{ + configMapGVK: { + {cluster: newFakeCluster(scheme), labels: map[string]string{"availabilityZone": "az-1"}}, + {cluster: newFakeCluster(scheme), labels: map[string]string{"availabilityZone": "az-2"}}, + }, + }, + } + labels := c.ConfiguredRouteLabels(configMapGVK) + if len(labels) != 2 { + t.Fatalf("expected 2 label sets, got %d", len(labels)) + } + if labels[0]["availabilityZone"] != "az-1" { + t.Errorf("expected az-1, got %s", labels[0]["availabilityZone"]) + } + if labels[1]["availabilityZone"] != "az-2" { + t.Errorf("expected az-2, got %s", labels[1]["availabilityZone"]) + } + }) +} + +func TestClient_ServedAZs(t *testing.T) { + scheme := newTestScheme(t) + + t.Run("returns nil for no remote clusters", func(t *testing.T) { + c := &Client{ + HomeCluster: newFakeCluster(scheme), + HomeScheme: scheme, + homeGVKs: map[schema.GroupVersionKind]bool{configMapGVK: true}, + } + azs := c.ServedAZs(configMapGVK) + if azs != nil { + t.Errorf("expected nil, got %v", azs) + } + }) + + t.Run("returns nil when labels have no availabilityZone key", func(t *testing.T) { + c := &Client{ + HomeCluster: newFakeCluster(scheme), + HomeScheme: scheme, + remoteClusters: map[schema.GroupVersionKind][]remoteCluster{ + configMapGVK: { + {cluster: newFakeCluster(scheme), labels: map[string]string{"region": "eu"}}, + }, + }, + } + azs := c.ServedAZs(configMapGVK) + if azs != nil { + t.Errorf("expected nil, got %v", azs) + } + }) + + t.Run("returns AZ set from remote cluster labels", func(t *testing.T) { + c := &Client{ + HomeCluster: newFakeCluster(scheme), + HomeScheme: scheme, + remoteClusters: map[schema.GroupVersionKind][]remoteCluster{ + configMapGVK: { + {cluster: newFakeCluster(scheme), labels: map[string]string{"availabilityZone": "az-1"}}, + {cluster: newFakeCluster(scheme), labels: map[string]string{"availabilityZone": "az-2"}}, + {cluster: newFakeCluster(scheme), labels: map[string]string{"availabilityZone": "az-3"}}, + }, + }, + } + azs := c.ServedAZs(configMapGVK) + if len(azs) != 3 { + t.Fatalf("expected 3 AZs, got %d", len(azs)) + } + for _, az := range []string{"az-1", "az-2", "az-3"} { + if !azs[az] { + t.Errorf("expected %s to be served", az) + } + } + if azs["az-4"] { + t.Error("az-4 should not be served") + } + }) +} From e02ef42e2b5d4ae8f56c435c67e9639b1d5710ce Mon Sep 17 00:00:00 2001 From: Malte Viering Date: Mon, 18 May 2026 09:07:05 +0000 Subject: [PATCH 2/4] fix: return defensive copies from ConfiguredRouteLabels Address PR review feedback: use maps.Copy to return copies of internal label maps instead of exposing the originals directly, preventing accidental mutation of routing state by callers. --- pkg/multicluster/client.go | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/pkg/multicluster/client.go b/pkg/multicluster/client.go index 331e22acf..0bf798fc0 100644 --- a/pkg/multicluster/client.go +++ b/pkg/multicluster/client.go @@ -7,6 +7,7 @@ import ( "context" "errors" "fmt" + "maps" "strings" "sync" @@ -279,11 +280,17 @@ func (c *Client) ConfiguredRouteLabels(gvk schema.GroupVersionKind) []map[string if len(remotes) == 0 { return nil } - labels := make([]map[string]string, 0, len(remotes)) + result := make([]map[string]string, 0, len(remotes)) for _, r := range remotes { - labels = append(labels, r.labels) + if r.labels == nil { + result = append(result, nil) + continue + } + cp := make(map[string]string, len(r.labels)) + maps.Copy(cp, r.labels) + result = append(result, cp) } - return labels + return result } // ServedAZs returns the set of availability zones that have a configured From 25afd51ad71c38360eedf062852ea6fae482601d Mon Sep 17 00:00:00 2001 From: Malte Viering Date: Mon, 18 May 2026 09:37:18 +0000 Subject: [PATCH 3/4] refactor: replace ServedAZs with config-based quota AZ filtering - Remove ServedAZs() from multicluster client (relied on label convention) - Add QuotaServedAvailabilityZones config field to APIConfig for explicit pre-filtering of AZs in quota handling - Keep IsNoClusterMatchedError fallback as safety net for unmatched AZs - Return defensive copies from ConfiguredRouteLabels (PR review feedback) --- .../reservations/commitments/api/quota.go | 23 ++++---- .../reservations/commitments/config.go | 4 ++ pkg/multicluster/client.go | 21 ------- pkg/multicluster/client_test.go | 58 ------------------- 4 files changed, 15 insertions(+), 91 deletions(-) diff --git a/internal/scheduling/reservations/commitments/api/quota.go b/internal/scheduling/reservations/commitments/api/quota.go index 907433b34..4a3ca5175 100644 --- a/internal/scheduling/reservations/commitments/api/quota.go +++ b/internal/scheduling/reservations/commitments/api/quota.go @@ -139,15 +139,17 @@ func (api *HTTPAPI) HandleQuota(w http.ResponseWriter, r *http.Request) { ctx := r.Context() - // Pre-filter: skip AZs that have no configured cluster. In multi-AZ setups - // Limes sends quota for all AZs but cortex only manages a subset (KVM AZs). - if mc, ok := api.client.(*multicluster.Client); ok { - if servedAZs := mc.ServedAZs(projectQuotaGVK); servedAZs != nil { - for az := range quotaByAZ { - if !servedAZs[az] { - log.V(1).Info("skipping quota for unserved AZ (no cluster configured)", "az", az, "projectID", projectID) - delete(quotaByAZ, az) - } + // Pre-filter: if served AZs are explicitly configured, skip AZs not in the list. + // In multi-AZ setups Limes sends quota for all AZs but cortex only manages a subset. + if len(api.config.QuotaServedAvailabilityZones) > 0 { + served := make(map[string]bool, len(api.config.QuotaServedAvailabilityZones)) + for _, az := range api.config.QuotaServedAvailabilityZones { + served[az] = true + } + for az := range quotaByAZ { + if !served[az] { + log.V(1).Info("skipping quota for unconfigured AZ", "az", az, "projectID", projectID) + delete(quotaByAZ, az) } } } @@ -261,6 +263,3 @@ func (api *HTTPAPI) recordQuotaMetrics(statusCode int, startTime time.Time) { api.quotaMonitor.requestCounter.WithLabelValues(statusCodeStr).Inc() api.quotaMonitor.requestDuration.WithLabelValues(statusCodeStr).Observe(duration) } - -// projectQuotaGVK is the GVK used by the multicluster client for ProjectQuota routing. -var projectQuotaGVK = schema.GroupVersionKind{Group: "cortex.cloud", Version: "v1alpha1", Kind: "ProjectQuota"} diff --git a/internal/scheduling/reservations/commitments/config.go b/internal/scheduling/reservations/commitments/config.go index e4005df47..9deee09a9 100644 --- a/internal/scheduling/reservations/commitments/config.go +++ b/internal/scheduling/reservations/commitments/config.go @@ -131,6 +131,10 @@ type APIConfig struct { WatchPollInterval metav1.Duration `json:"watchPollInterval"` // FlavorGroupResourceConfig maps flavor group IDs to resource flag configs; "*" acts as catch-all. FlavorGroupResourceConfig map[string]FlavorGroupResourcesConfig `json:"flavorGroupResourceConfig,omitempty"` + // QuotaServedAvailabilityZones restricts quota handling to these AZs. + // Quota received for AZs not in this list is silently skipped. + // If empty/nil, no pre-filtering is applied (relies on error-based fallback). + QuotaServedAvailabilityZones []string `json:"quotaServedAvailabilityZones,omitempty"` } // ResourceConfigForGroup returns the resource config for the given flavor group ID, diff --git a/pkg/multicluster/client.go b/pkg/multicluster/client.go index 0bf798fc0..d4aaa7b85 100644 --- a/pkg/multicluster/client.go +++ b/pkg/multicluster/client.go @@ -293,27 +293,6 @@ func (c *Client) ConfiguredRouteLabels(gvk schema.GroupVersionKind) []map[string return result } -// ServedAZs returns the set of availability zones that have a configured -// remote cluster for the given GVK. It extracts the "availabilityZone" key -// from each remote cluster's routing labels. Returns nil if no remote clusters -// are configured or none have an "availabilityZone" label. -func (c *Client) ServedAZs(gvk schema.GroupVersionKind) map[string]bool { - labelSets := c.ConfiguredRouteLabels(gvk) - if len(labelSets) == 0 { - return nil - } - served := make(map[string]bool, len(labelSets)) - for _, labels := range labelSets { - if az, ok := labels["availabilityZone"]; ok { - served[az] = true - } - } - if len(served) == 0 { - return nil - } - return served -} - // Get iterates over all clusters with the GVK and returns the result. // // If the requested resource is encountered in multiple clusters, this function diff --git a/pkg/multicluster/client_test.go b/pkg/multicluster/client_test.go index fdbb6779c..6ed7215a3 100644 --- a/pkg/multicluster/client_test.go +++ b/pkg/multicluster/client_test.go @@ -1718,61 +1718,3 @@ func TestClient_ConfiguredRouteLabels(t *testing.T) { } }) } - -func TestClient_ServedAZs(t *testing.T) { - scheme := newTestScheme(t) - - t.Run("returns nil for no remote clusters", func(t *testing.T) { - c := &Client{ - HomeCluster: newFakeCluster(scheme), - HomeScheme: scheme, - homeGVKs: map[schema.GroupVersionKind]bool{configMapGVK: true}, - } - azs := c.ServedAZs(configMapGVK) - if azs != nil { - t.Errorf("expected nil, got %v", azs) - } - }) - - t.Run("returns nil when labels have no availabilityZone key", func(t *testing.T) { - c := &Client{ - HomeCluster: newFakeCluster(scheme), - HomeScheme: scheme, - remoteClusters: map[schema.GroupVersionKind][]remoteCluster{ - configMapGVK: { - {cluster: newFakeCluster(scheme), labels: map[string]string{"region": "eu"}}, - }, - }, - } - azs := c.ServedAZs(configMapGVK) - if azs != nil { - t.Errorf("expected nil, got %v", azs) - } - }) - - t.Run("returns AZ set from remote cluster labels", func(t *testing.T) { - c := &Client{ - HomeCluster: newFakeCluster(scheme), - HomeScheme: scheme, - remoteClusters: map[schema.GroupVersionKind][]remoteCluster{ - configMapGVK: { - {cluster: newFakeCluster(scheme), labels: map[string]string{"availabilityZone": "az-1"}}, - {cluster: newFakeCluster(scheme), labels: map[string]string{"availabilityZone": "az-2"}}, - {cluster: newFakeCluster(scheme), labels: map[string]string{"availabilityZone": "az-3"}}, - }, - }, - } - azs := c.ServedAZs(configMapGVK) - if len(azs) != 3 { - t.Fatalf("expected 3 AZs, got %d", len(azs)) - } - for _, az := range []string{"az-1", "az-2", "az-3"} { - if !azs[az] { - t.Errorf("expected %s to be served", az) - } - } - if azs["az-4"] { - t.Error("az-4 should not be served") - } - }) -} From 7420ee855ec7bfc064f245525b31904c26291c67 Mon Sep 17 00:00:00 2001 From: Malte Viering Date: Mon, 18 May 2026 09:45:02 +0000 Subject: [PATCH 4/4] test: add quota tests for AZ pre-filter and NoClusterMatchedError skip - TestHandleQuota_QuotaServedAvailabilityZones: verifies config-based pre-filter only creates CRDs for configured AZs - TestHandleQuota_NoClusterMatchedError: verifies graceful skip when Create returns NoClusterMatchedError for an unserved AZ --- .../commitments/api/quota_test.go | 135 ++++++++++++++++++ 1 file changed, 135 insertions(+) diff --git a/internal/scheduling/reservations/commitments/api/quota_test.go b/internal/scheduling/reservations/commitments/api/quota_test.go index 074976289..766e93741 100644 --- a/internal/scheduling/reservations/commitments/api/quota_test.go +++ b/internal/scheduling/reservations/commitments/api/quota_test.go @@ -9,10 +9,12 @@ import ( "encoding/json" "net/http" "net/http/httptest" + "strings" "testing" "github.com/cobaltcore-dev/cortex/api/v1alpha1" commitments "github.com/cobaltcore-dev/cortex/internal/scheduling/reservations/commitments" + "github.com/cobaltcore-dev/cortex/pkg/multicluster" "github.com/sapcc/go-api-declarations/liquid" "go.xyrillian.de/gg/option" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -415,6 +417,139 @@ func boolPtr(b bool) *bool { return &b } +// TestHandleQuota_QuotaServedAvailabilityZones verifies that the config-based AZ +// pre-filter skips AZs not listed in QuotaServedAvailabilityZones. +func TestHandleQuota_QuotaServedAvailabilityZones(t *testing.T) { + scheme := newTestScheme(t) + knowledge := quotaTestKnowledge1GiB(t) + k8sClient := fake.NewClientBuilder().WithScheme(scheme).WithObjects(knowledge).Build() + + config := commitments.DefaultAPIConfig() + config.QuotaServedAvailabilityZones = []string{"az-a"} // only az-a is served + httpAPI := NewAPIWithConfig(k8sClient, config, nil) + + quotaReq := liquid.ServiceQuotaRequest{ + Resources: map[liquid.ResourceName]liquid.ResourceQuotaRequest{ + "hw_version_hana_1_ram": { + PerAZ: map[liquid.AvailabilityZone]liquid.AZResourceQuotaRequest{ + "az-a": {Quota: 50}, + "az-b": {Quota: 30}, // should be skipped + "az-c": {Quota: 20}, // should be skipped + }, + }, + }, + } + quotaReq.ProjectMetadata = option.Some(liquid.ProjectMetadata{ + UUID: "project-filter", + Domain: liquid.DomainMetadata{UUID: "domain-1"}, + }) + body := marshalQuotaReq(t, quotaReq) + + req := httptest.NewRequest(http.MethodPut, "/commitments/v1/projects/project-filter/quota", bytes.NewReader(body)) + w := httptest.NewRecorder() + httpAPI.HandleQuota(w, req) + + if w.Code != http.StatusNoContent { + t.Fatalf("expected 204, got %d: %s", w.Code, w.Body.String()) + } + + // az-a should have a CRD + var pq v1alpha1.ProjectQuota + if err := k8sClient.Get(context.Background(), client.ObjectKey{Name: "quota-project-filter-az-a"}, &pq); err != nil { + t.Fatalf("expected ProjectQuota for az-a: %v", err) + } + if pq.Spec.Quota["hw_version_hana_1_ram"] != 50 { + t.Errorf("az-a: expected quota 50, got %d", pq.Spec.Quota["hw_version_hana_1_ram"]) + } + + // az-b and az-c should NOT have CRDs + err := k8sClient.Get(context.Background(), client.ObjectKey{Name: "quota-project-filter-az-b"}, &pq) + if err == nil { + t.Error("expected no ProjectQuota for az-b (should be filtered)") + } + err = k8sClient.Get(context.Background(), client.ObjectKey{Name: "quota-project-filter-az-c"}, &pq) + if err == nil { + t.Error("expected no ProjectQuota for az-c (should be filtered)") + } +} + +// noClusterClient wraps a client and returns NoClusterMatchedError on Create +// for objects whose name contains a specific AZ suffix. +type noClusterClient struct { + client.Client + unservedAZs []string +} + +func (c *noClusterClient) Create(ctx context.Context, obj client.Object, opts ...client.CreateOption) error { + for _, az := range c.unservedAZs { + if strings.HasSuffix(obj.GetName(), "-"+az) { + return &multicluster.NoClusterMatchedError{} + } + } + return c.Client.Create(ctx, obj, opts...) +} + +func (c *noClusterClient) Update(ctx context.Context, obj client.Object, opts ...client.UpdateOption) error { + for _, az := range c.unservedAZs { + if strings.HasSuffix(obj.GetName(), "-"+az) { + return &multicluster.NoClusterMatchedError{} + } + } + return c.Client.Update(ctx, obj, opts...) +} + +// TestHandleQuota_NoClusterMatchedError verifies that when a Create returns +// NoClusterMatchedError for a specific AZ, the handler skips it gracefully +// and still succeeds for the remaining AZs. +func TestHandleQuota_NoClusterMatchedError(t *testing.T) { + scheme := newTestScheme(t) + knowledge := quotaTestKnowledge1GiB(t) + baseClient := fake.NewClientBuilder().WithScheme(scheme).WithObjects(knowledge).Build() + + // Wrap client so az-b returns NoClusterMatchedError on Create. + wrapped := &noClusterClient{Client: baseClient, unservedAZs: []string{"az-b"}} + httpAPI := NewAPI(wrapped) + + quotaReq := liquid.ServiceQuotaRequest{ + Resources: map[liquid.ResourceName]liquid.ResourceQuotaRequest{ + "hw_version_hana_1_ram": { + PerAZ: map[liquid.AvailabilityZone]liquid.AZResourceQuotaRequest{ + "az-a": {Quota: 70}, + "az-b": {Quota: 30}, // will hit NoClusterMatchedError + }, + }, + }, + } + quotaReq.ProjectMetadata = option.Some(liquid.ProjectMetadata{ + UUID: "project-nocluster", + Domain: liquid.DomainMetadata{UUID: "domain-1"}, + }) + body := marshalQuotaReq(t, quotaReq) + + req := httptest.NewRequest(http.MethodPut, "/commitments/v1/projects/project-nocluster/quota", bytes.NewReader(body)) + w := httptest.NewRecorder() + httpAPI.HandleQuota(w, req) + + if w.Code != http.StatusNoContent { + t.Fatalf("expected 204, got %d: %s", w.Code, w.Body.String()) + } + + // az-a should have a CRD + var pq v1alpha1.ProjectQuota + if err := baseClient.Get(context.Background(), client.ObjectKey{Name: "quota-project-nocluster-az-a"}, &pq); err != nil { + t.Fatalf("expected ProjectQuota for az-a: %v", err) + } + if pq.Spec.Quota["hw_version_hana_1_ram"] != 70 { + t.Errorf("az-a: expected quota 70, got %d", pq.Spec.Quota["hw_version_hana_1_ram"]) + } + + // az-b should NOT have a CRD (gracefully skipped) + err := baseClient.Get(context.Background(), client.ObjectKey{Name: "quota-project-nocluster-az-b"}, &pq) + if err == nil { + t.Error("expected no ProjectQuota for az-b (NoClusterMatchedError should skip it)") + } +} + // TestHandleQuota_KnowledgeNotReady verifies that the quota endpoint returns 503 when // the flavor-group Knowledge CRD is absent (needed for unit conversion). func TestHandleQuota_KnowledgeNotReady(t *testing.T) {