diff --git a/api/v1alpha1/project_quota_types.go b/api/v1alpha1/project_quota_types.go new file mode 100644 index 000000000..cf61585c6 --- /dev/null +++ b/api/v1alpha1/project_quota_types.go @@ -0,0 +1,122 @@ +// Copyright SAP SE +// SPDX-License-Identifier: Apache-2.0 + +package v1alpha1 + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// ResourceQuota holds the quota for a single resource with per-AZ breakdown. +// Maps to liquid.ResourceQuotaRequest from the LIQUID API. +// See: https://pkg.go.dev/github.com/sapcc/go-api-declarations/liquid#ResourceQuotaRequest +type ResourceQuota struct { + // Quota is the total quota across all AZs (for compatibility). + // Corresponds to liquid.ResourceQuotaRequest.Quota. + // +kubebuilder:validation:Required + Quota int64 `json:"quota"` + + // PerAZ holds the per-availability-zone quota breakdown. + // Key: availability zone name, Value: quota for that AZ. + // Only populated for AZSeparatedTopology resources. + // Corresponds to liquid.ResourceQuotaRequest.PerAZ[az].Quota. + // See: https://pkg.go.dev/github.com/sapcc/go-api-declarations/liquid#AZResourceQuotaRequest + // +kubebuilder:validation:Optional + PerAZ map[string]int64 `json:"perAZ,omitempty"` +} + +// ResourceQuotaUsage holds per-AZ PAYG usage for a single resource. +type ResourceQuotaUsage struct { + // PerAZ holds per-availability-zone PAYG usage values. + // Key: availability zone name, Value: PAYG usage in that AZ. + // +kubebuilder:validation:Optional + PerAZ map[string]int64 `json:"perAZ,omitempty"` +} + +// ProjectQuotaSpec defines the desired state of ProjectQuota. +// Populated from PUT /v1/projects/:uuid/quota payloads (liquid.ServiceQuotaRequest). +// See: https://pkg.go.dev/github.com/sapcc/go-api-declarations/liquid#ServiceQuotaRequest +type ProjectQuotaSpec struct { + // ProjectID of the OpenStack project this quota belongs to. + // Corresponds to the :uuid in the PUT URL path. + // +kubebuilder:validation:Required + ProjectID string `json:"projectID"` + + // ProjectName is the human-readable name of the OpenStack project. + // Extracted from liquid.ServiceQuotaRequest.ProjectMetadata.Name. + // +kubebuilder:validation:Optional + ProjectName string `json:"projectName,omitempty"` + + // DomainID of the OpenStack domain this project belongs to. + // Extracted from liquid.ServiceQuotaRequest.ProjectMetadata.Domain.UUID. + // +kubebuilder:validation:Required + DomainID string `json:"domainID"` + + // DomainName is the human-readable name of the OpenStack domain. + // Extracted from liquid.ServiceQuotaRequest.ProjectMetadata.Domain.Name. + // +kubebuilder:validation:Optional + DomainName string `json:"domainName,omitempty"` + + // Quota maps LIQUID resource names to their per-AZ quota. + // Key: liquid.ResourceName (e.g. "hw_version_hana_v2_ram") + // Mirrors liquid.ServiceQuotaRequest.Resources with AZSeparatedTopology. + // See: https://pkg.go.dev/github.com/sapcc/go-api-declarations/liquid#ServiceQuotaRequest + // +kubebuilder:validation:Optional + Quota map[string]ResourceQuota `json:"quota,omitempty"` +} + +// ProjectQuotaStatus defines the observed state of ProjectQuota. +// Usage values correspond to liquid.AZResourceUsageReport fields reported via /report-usage. +// See: https://pkg.go.dev/github.com/sapcc/go-api-declarations/liquid#AZResourceUsageReport +type ProjectQuotaStatus struct { + // PaygUsage tracks per-resource per-AZ pay-as-you-go usage. + // Key: liquid.ResourceName + // +kubebuilder:validation:Optional + PaygUsage map[string]ResourceQuotaUsage `json:"paygUsage,omitempty"` + + // LastReconcileAt is when the controller last reconciled this project's quota. + // +kubebuilder:validation:Optional + LastReconcileAt *metav1.Time `json:"lastReconcileAt,omitempty"` + + // Conditions holds the current status conditions. + // +kubebuilder:validation:Optional + Conditions []metav1.Condition `json:"conditions,omitempty" patchStrategy:"merge" patchMergeKey:"type"` +} + +// +kubebuilder:object:root=true +// +kubebuilder:subresource:status +// +kubebuilder:resource:scope=Cluster +// +kubebuilder:printcolumn:name="Project",type="string",JSONPath=".spec.projectID" +// +kubebuilder:printcolumn:name="Domain",type="string",JSONPath=".spec.domainID" +// +kubebuilder:printcolumn:name="LastReconcile",type="date",JSONPath=".status.lastReconcileAt" +// +kubebuilder:printcolumn:name="Ready",type="string",JSONPath=".status.conditions[?(@.type=='Ready')].status" + +// ProjectQuota is the Schema for the projectquotas API. +// It persists quota values pushed by Limes via the LIQUID quota endpoint +// (PUT /v1/projects/:uuid/quota → liquid.ServiceQuotaRequest). +// See: https://pkg.go.dev/github.com/sapcc/go-api-declarations/liquid#ServiceQuotaRequest +type ProjectQuota struct { + metav1.TypeMeta `json:",inline"` + + // +optional + metav1.ObjectMeta `json:"metadata,omitempty,omitzero"` + + // +required + Spec ProjectQuotaSpec `json:"spec"` + + // +optional + Status ProjectQuotaStatus `json:"status,omitempty,omitzero"` +} + +// +kubebuilder:object:root=true + +// ProjectQuotaList contains a list of ProjectQuota +type ProjectQuotaList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []ProjectQuota `json:"items"` +} + +func init() { + SchemeBuilder.Register(&ProjectQuota{}, &ProjectQuotaList{}) +} diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index d9daa7aab..1a4bc222a 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -1420,6 +1420,120 @@ func (in *PlacementDatasource) DeepCopy() *PlacementDatasource { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ProjectQuota) DeepCopyInto(out *ProjectQuota) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + in.Spec.DeepCopyInto(&out.Spec) + in.Status.DeepCopyInto(&out.Status) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ProjectQuota. +func (in *ProjectQuota) DeepCopy() *ProjectQuota { + if in == nil { + return nil + } + out := new(ProjectQuota) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *ProjectQuota) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ProjectQuotaList) DeepCopyInto(out *ProjectQuotaList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]ProjectQuota, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ProjectQuotaList. +func (in *ProjectQuotaList) DeepCopy() *ProjectQuotaList { + if in == nil { + return nil + } + out := new(ProjectQuotaList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *ProjectQuotaList) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ProjectQuotaSpec) DeepCopyInto(out *ProjectQuotaSpec) { + *out = *in + if in.Quota != nil { + in, out := &in.Quota, &out.Quota + *out = make(map[string]ResourceQuota, len(*in)) + for key, val := range *in { + (*out)[key] = *val.DeepCopy() + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ProjectQuotaSpec. +func (in *ProjectQuotaSpec) DeepCopy() *ProjectQuotaSpec { + if in == nil { + return nil + } + out := new(ProjectQuotaSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ProjectQuotaStatus) DeepCopyInto(out *ProjectQuotaStatus) { + *out = *in + if in.PaygUsage != nil { + in, out := &in.PaygUsage, &out.PaygUsage + *out = make(map[string]ResourceQuotaUsage, len(*in)) + for key, val := range *in { + (*out)[key] = *val.DeepCopy() + } + } + if in.LastReconcileAt != nil { + in, out := &in.LastReconcileAt, &out.LastReconcileAt + *out = (*in).DeepCopy() + } + if in.Conditions != nil { + in, out := &in.Conditions, &out.Conditions + *out = make([]v1.Condition, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ProjectQuotaStatus. +func (in *ProjectQuotaStatus) DeepCopy() *ProjectQuotaStatus { + if in == nil { + return nil + } + out := new(ProjectQuotaStatus) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *PrometheusDatasource) DeepCopyInto(out *PrometheusDatasource) { *out = *in @@ -1570,6 +1684,50 @@ func (in *ReservationStatus) DeepCopy() *ReservationStatus { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ResourceQuota) DeepCopyInto(out *ResourceQuota) { + *out = *in + if in.PerAZ != nil { + in, out := &in.PerAZ, &out.PerAZ + *out = make(map[string]int64, len(*in)) + for key, val := range *in { + (*out)[key] = val + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ResourceQuota. +func (in *ResourceQuota) DeepCopy() *ResourceQuota { + if in == nil { + return nil + } + out := new(ResourceQuota) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ResourceQuotaUsage) DeepCopyInto(out *ResourceQuotaUsage) { + *out = *in + if in.PerAZ != nil { + in, out := &in.PerAZ, &out.PerAZ + *out = make(map[string]int64, len(*in)) + for key, val := range *in { + (*out)[key] = val + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ResourceQuotaUsage. +func (in *ResourceQuotaUsage) DeepCopy() *ResourceQuotaUsage { + if in == nil { + return nil + } + out := new(ResourceQuotaUsage) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *SchedulingHistoryEntry) DeepCopyInto(out *SchedulingHistoryEntry) { *out = *in diff --git a/helm/library/cortex/files/crds/cortex.cloud_projectquotas.yaml b/helm/library/cortex/files/crds/cortex.cloud_projectquotas.yaml new file mode 100644 index 000000000..07e39aaa0 --- /dev/null +++ b/helm/library/cortex/files/crds/cortex.cloud_projectquotas.yaml @@ -0,0 +1,212 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.20.1 + name: projectquotas.cortex.cloud +spec: + group: cortex.cloud + names: + kind: ProjectQuota + listKind: ProjectQuotaList + plural: projectquotas + singular: projectquota + scope: Cluster + versions: + - additionalPrinterColumns: + - jsonPath: .spec.projectID + name: Project + type: string + - jsonPath: .spec.domainID + name: Domain + type: string + - jsonPath: .status.lastReconcileAt + name: LastReconcile + type: date + - jsonPath: .status.conditions[?(@.type=='Ready')].status + name: Ready + type: string + name: v1alpha1 + schema: + openAPIV3Schema: + description: |- + ProjectQuota is the Schema for the projectquotas API. + It persists quota values pushed by Limes via the LIQUID quota endpoint + (PUT /v1/projects/:uuid/quota → liquid.ServiceQuotaRequest). + See: https://pkg.go.dev/github.com/sapcc/go-api-declarations/liquid#ServiceQuotaRequest + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + description: |- + ProjectQuotaSpec defines the desired state of ProjectQuota. + Populated from PUT /v1/projects/:uuid/quota payloads (liquid.ServiceQuotaRequest). + See: https://pkg.go.dev/github.com/sapcc/go-api-declarations/liquid#ServiceQuotaRequest + properties: + domainID: + description: |- + DomainID of the OpenStack domain this project belongs to. + Extracted from liquid.ServiceQuotaRequest.ProjectMetadata.Domain.UUID. + type: string + domainName: + description: |- + DomainName is the human-readable name of the OpenStack domain. + Extracted from liquid.ServiceQuotaRequest.ProjectMetadata.Domain.Name. + type: string + projectID: + description: |- + ProjectID of the OpenStack project this quota belongs to. + Corresponds to the :uuid in the PUT URL path. + type: string + projectName: + description: |- + ProjectName is the human-readable name of the OpenStack project. + Extracted from liquid.ServiceQuotaRequest.ProjectMetadata.Name. + type: string + quota: + additionalProperties: + description: |- + ResourceQuota holds the quota for a single resource with per-AZ breakdown. + Maps to liquid.ResourceQuotaRequest from the LIQUID API. + See: https://pkg.go.dev/github.com/sapcc/go-api-declarations/liquid#ResourceQuotaRequest + properties: + perAZ: + additionalProperties: + format: int64 + type: integer + description: |- + PerAZ holds the per-availability-zone quota breakdown. + Key: availability zone name, Value: quota for that AZ. + Only populated for AZSeparatedTopology resources. + Corresponds to liquid.ResourceQuotaRequest.PerAZ[az].Quota. + See: https://pkg.go.dev/github.com/sapcc/go-api-declarations/liquid#AZResourceQuotaRequest + type: object + quota: + description: |- + Quota is the total quota across all AZs (for compatibility). + Corresponds to liquid.ResourceQuotaRequest.Quota. + format: int64 + type: integer + required: + - quota + type: object + description: |- + Quota maps LIQUID resource names to their per-AZ quota. + Key: liquid.ResourceName (e.g. "hw_version_hana_v2_ram") + Mirrors liquid.ServiceQuotaRequest.Resources with AZSeparatedTopology. + See: https://pkg.go.dev/github.com/sapcc/go-api-declarations/liquid#ServiceQuotaRequest + type: object + required: + - domainID + - projectID + type: object + status: + description: |- + ProjectQuotaStatus defines the observed state of ProjectQuota. + Usage values correspond to liquid.AZResourceUsageReport fields reported via /report-usage. + See: https://pkg.go.dev/github.com/sapcc/go-api-declarations/liquid#AZResourceUsageReport + properties: + conditions: + description: Conditions holds the current status conditions. + items: + description: Condition contains details for one aspect of the current + state of this API Resource. + properties: + lastTransitionTime: + description: |- + lastTransitionTime is the last time the condition transitioned from one status to another. + This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: |- + message is a human readable message indicating details about the transition. + This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: |- + observedGeneration represents the .metadata.generation that the condition was set based upon. + For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date + with respect to the current state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: |- + reason contains a programmatic identifier indicating the reason for the condition's last transition. + Producers of specific condition types may define expected values and meanings for this field, + and whether the values are considered a guaranteed API. + The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + lastReconcileAt: + description: LastReconcileAt is when the controller last reconciled + this project's quota. + format: date-time + type: string + paygUsage: + additionalProperties: + description: ResourceQuotaUsage holds per-AZ PAYG usage for a single + resource. + properties: + perAZ: + additionalProperties: + format: int64 + type: integer + description: |- + PerAZ holds per-availability-zone PAYG usage values. + Key: availability zone name, Value: PAYG usage in that AZ. + type: object + type: object + description: |- + PaygUsage tracks per-resource per-AZ pay-as-you-go usage. + Key: liquid.ResourceName + type: object + type: object + required: + - spec + type: object + served: true + storage: true + subresources: + status: {} diff --git a/internal/scheduling/reservations/commitments/api/handler.go b/internal/scheduling/reservations/commitments/api/handler.go index f0eb24110..413bad31f 100644 --- a/internal/scheduling/reservations/commitments/api/handler.go +++ b/internal/scheduling/reservations/commitments/api/handler.go @@ -26,6 +26,7 @@ type HTTPAPI struct { usageMonitor ReportUsageAPIMonitor capacityMonitor ReportCapacityAPIMonitor infoMonitor InfoAPIMonitor + quotaMonitor QuotaAPIMonitor // Mutex to serialize change-commitments requests changeMutex sync.Mutex } @@ -44,6 +45,7 @@ func NewAPIWithConfig(k8sClient client.Client, config commitments.Config, usageD usageMonitor: NewReportUsageAPIMonitor(), capacityMonitor: NewReportCapacityAPIMonitor(), infoMonitor: NewInfoAPIMonitor(), + quotaMonitor: NewQuotaAPIMonitor(), } } @@ -52,6 +54,7 @@ func (api *HTTPAPI) Init(mux *http.ServeMux, registry prometheus.Registerer, log registry.MustRegister(&api.usageMonitor) registry.MustRegister(&api.capacityMonitor) registry.MustRegister(&api.infoMonitor) + registry.MustRegister(&api.quotaMonitor) mux.HandleFunc("/commitments/v1/change-commitments", api.HandleChangeCommitments) mux.HandleFunc("/commitments/v1/report-capacity", api.HandleReportCapacity) mux.HandleFunc("/commitments/v1/info", api.HandleInfo) diff --git a/internal/scheduling/reservations/commitments/api/info.go b/internal/scheduling/reservations/commitments/api/info.go index 6999b38d6..f16f71301 100644 --- a/internal/scheduling/reservations/commitments/api/info.go +++ b/internal/scheduling/reservations/commitments/api/info.go @@ -151,6 +151,12 @@ func (api *HTTPAPI) buildServiceInfo(ctx context.Context, logger logr.Logger) (l 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 + if handlesCommitments { + ramTopology = liquid.AZSeparatedTopology + } resources[ramResourceName] = liquid.ResourceInfo{ DisplayName: fmt.Sprintf( "multiples of %d MiB (usable by: %s)", @@ -158,10 +164,10 @@ func (api *HTTPAPI) buildServiceInfo(ctx context.Context, logger logr.Logger) (l flavorListStr, ), Unit: ramUnit, // Non-standard unit: multiples of smallest flavor RAM - Topology: liquid.AZAwareTopology, + Topology: ramTopology, NeedsResourceDemand: false, - HasCapacity: true, // We report capacity via /commitments/v1/report-capacity - HasQuota: false, + HasCapacity: true, // We report capacity via /commitments/v1/report-capacity + HasQuota: handlesCommitments, // true only for groups that accept commitments HandlesCommitments: handlesCommitments, // Only groups with fixed ratio accept commitments Attributes: attrsJSON, } diff --git a/internal/scheduling/reservations/commitments/api/info_test.go b/internal/scheduling/reservations/commitments/api/info_test.go index 48e12fd2c..3ca0bd11c 100644 --- a/internal/scheduling/reservations/commitments/api/info_test.go +++ b/internal/scheduling/reservations/commitments/api/info_test.go @@ -224,7 +224,7 @@ func TestHandleInfo_HasCapacityEqualsHandlesCommitments(t *testing.T) { t.Fatalf("expected 6 resources (3 per flavor group), got %d", len(serviceInfo.Resources)) } - // Test RAM resource: hw_version_hana_fixed_ram + // Test RAM resource: hw_version_hana_fixed_ram (fixed ratio → commitments + quota) ramResource, ok := serviceInfo.Resources["hw_version_hana_fixed_ram"] if !ok { t.Fatal("expected hw_version_hana_fixed_ram resource to exist") @@ -235,8 +235,14 @@ func TestHandleInfo_HasCapacityEqualsHandlesCommitments(t *testing.T) { if !ramResource.HandlesCommitments { t.Error("hw_version_hana_fixed_ram: expected HandlesCommitments=true (RAM is primary commitment resource)") } + if ramResource.Topology != liquid.AZSeparatedTopology { + t.Errorf("hw_version_hana_fixed_ram: expected Topology=%q, got %q", liquid.AZSeparatedTopology, ramResource.Topology) + } + if !ramResource.HasQuota { + t.Error("hw_version_hana_fixed_ram: expected HasQuota=true (fixed ratio groups accept quotas)") + } - // Test Cores resource: hw_version_hana_fixed_cores + // Test Cores resource: hw_version_hana_fixed_cores (always AZAwareTopology, no quota) coresResource, ok := serviceInfo.Resources["hw_version_hana_fixed_cores"] if !ok { t.Fatal("expected hw_version_hana_fixed_cores resource to exist") @@ -247,8 +253,14 @@ func TestHandleInfo_HasCapacityEqualsHandlesCommitments(t *testing.T) { if coresResource.HandlesCommitments { t.Error("hw_version_hana_fixed_cores: expected HandlesCommitments=false (cores are derived)") } + if coresResource.Topology != liquid.AZAwareTopology { + t.Errorf("hw_version_hana_fixed_cores: expected Topology=%q, got %q", liquid.AZAwareTopology, coresResource.Topology) + } + if coresResource.HasQuota { + t.Error("hw_version_hana_fixed_cores: expected HasQuota=false") + } - // Test Instances resource: hw_version_hana_fixed_instances + // Test Instances resource: hw_version_hana_fixed_instances (always AZAwareTopology, no quota) instancesResource, ok := serviceInfo.Resources["hw_version_hana_fixed_instances"] if !ok { t.Fatal("expected hw_version_hana_fixed_instances resource to exist") @@ -259,8 +271,15 @@ func TestHandleInfo_HasCapacityEqualsHandlesCommitments(t *testing.T) { if instancesResource.HandlesCommitments { t.Error("hw_version_hana_fixed_instances: expected HandlesCommitments=false (instances are derived)") } + if instancesResource.Topology != liquid.AZAwareTopology { + t.Errorf("hw_version_hana_fixed_instances: expected Topology=%q, got %q", liquid.AZAwareTopology, instancesResource.Topology) + } + if instancesResource.HasQuota { + t.Error("hw_version_hana_fixed_instances: expected HasQuota=false") + } // Variable ratio group DOES have resources now, but HandlesCommitments=false for RAM + // Variable ratio → AZAwareTopology, no quota v2RamResource, ok := serviceInfo.Resources["hw_version_v2_variable_ram"] if !ok { t.Fatal("expected hw_version_v2_variable_ram resource to exist (all groups included)") @@ -271,6 +290,12 @@ func TestHandleInfo_HasCapacityEqualsHandlesCommitments(t *testing.T) { if v2RamResource.HandlesCommitments { t.Error("hw_version_v2_variable_ram: expected HandlesCommitments=false (variable ratio)") } + if v2RamResource.Topology != liquid.AZAwareTopology { + t.Errorf("hw_version_v2_variable_ram: expected Topology=%q, got %q", liquid.AZAwareTopology, v2RamResource.Topology) + } + if v2RamResource.HasQuota { + t.Error("hw_version_v2_variable_ram: expected HasQuota=false (variable ratio)") + } v2CoresResource, ok := serviceInfo.Resources["hw_version_v2_variable_cores"] if !ok { @@ -282,6 +307,12 @@ func TestHandleInfo_HasCapacityEqualsHandlesCommitments(t *testing.T) { if v2CoresResource.HandlesCommitments { t.Error("hw_version_v2_variable_cores: expected HandlesCommitments=false") } + if v2CoresResource.Topology != liquid.AZAwareTopology { + t.Errorf("hw_version_v2_variable_cores: expected Topology=%q, got %q", liquid.AZAwareTopology, v2CoresResource.Topology) + } + if v2CoresResource.HasQuota { + t.Error("hw_version_v2_variable_cores: expected HasQuota=false") + } v2InstancesResource, ok := serviceInfo.Resources["hw_version_v2_variable_instances"] if !ok { @@ -293,4 +324,10 @@ func TestHandleInfo_HasCapacityEqualsHandlesCommitments(t *testing.T) { if v2InstancesResource.HandlesCommitments { t.Error("hw_version_v2_variable_instances: expected HandlesCommitments=false") } + if v2InstancesResource.Topology != liquid.AZAwareTopology { + t.Errorf("hw_version_v2_variable_instances: expected Topology=%q, got %q", liquid.AZAwareTopology, v2InstancesResource.Topology) + } + if v2InstancesResource.HasQuota { + t.Error("hw_version_v2_variable_instances: expected HasQuota=false") + } } diff --git a/internal/scheduling/reservations/commitments/api/quota.go b/internal/scheduling/reservations/commitments/api/quota.go index c77fdf1a6..37b57d22a 100644 --- a/internal/scheduling/reservations/commitments/api/quota.go +++ b/internal/scheduling/reservations/commitments/api/quota.go @@ -4,19 +4,35 @@ package api import ( + "encoding/json" + "fmt" + "math" "net/http" + "strconv" + "time" + "github.com/cobaltcore-dev/cortex/api/v1alpha1" "github.com/google/uuid" + "github.com/sapcc/go-api-declarations/liquid" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/client" ) +// projectQuotaCRDName returns the CRD object name for a given project UUID. +// Convention: "quota-" +func projectQuotaCRDName(projectID string) string { + return "quota-" + projectID +} + // HandleQuota implements PUT /commitments/v1/projects/:project_id/quota from Limes LIQUID API. // See: https://pkg.go.dev/github.com/sapcc/go-api-declarations/liquid // -// This is a no-op endpoint that accepts quota requests but doesn't store them. -// Cortex does not enforce quotas for committed resources - quota enforcement -// happens through commitment validation at change-commitments time. -// The endpoint exists for API compatibility with the LIQUID specification. +// This endpoint receives quota requests from Limes and persists them as ProjectQuota CRDs. +// One CRD per project, named "quota-". func (api *HTTPAPI) HandleQuota(w http.ResponseWriter, r *http.Request) { + startTime := time.Now() + // Extract or generate request ID for tracing requestID := r.Header.Get("X-Request-ID") if requestID == "" { @@ -27,14 +43,138 @@ func (api *HTTPAPI) HandleQuota(w http.ResponseWriter, r *http.Request) { log := apiLog.WithValues("requestID", requestID, "endpoint", "quota") if r.Method != http.MethodPut { - http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + api.quotaError(w, http.StatusMethodNotAllowed, "Method not allowed", startTime) + return + } + + // Check if quota API is enabled + if !api.config.EnableQuotaAPI { + api.quotaError(w, http.StatusServiceUnavailable, "Quota API is disabled", startTime) + return + } + + // Extract project UUID from URL path + projectID, err := extractProjectIDFromPath(r.URL.Path) + if err != nil { + log.Error(err, "failed to extract project ID from path") + api.quotaError(w, http.StatusBadRequest, "Invalid URL path: "+err.Error(), startTime) + return + } + + // Parse request body + var req liquid.ServiceQuotaRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + log.Error(err, "failed to decode quota request body") + api.quotaError(w, http.StatusBadRequest, "Invalid request body: "+err.Error(), startTime) return } - // No-op: Accept the quota request but don't store it - // Cortex handles capacity through commitments, not quotas - log.V(1).Info("received quota request (no-op)", "path", r.URL.Path) + // Extract project/domain metadata if available + var projectName, domainID, domainName string + if meta, ok := req.ProjectMetadata.Unpack(); ok { + // Consistency check: metadata UUID must match URL path UUID + if meta.UUID != "" && meta.UUID != projectID { + log.Info("project UUID mismatch", "urlProjectID", projectID, "metadataUUID", meta.UUID) + api.quotaError(w, http.StatusBadRequest, fmt.Sprintf("Project UUID mismatch: URL has %q but metadata has %q", projectID, meta.UUID), startTime) + return + } + projectName = meta.Name + domainID = meta.Domain.UUID + domainName = meta.Domain.Name + } + + // Build the spec quota map from the liquid request. + // liquid API uses uint64; our CRD uses int64 (K8s convention). + // Guard against overflow: uint64 values > MaxInt64 would wrap to negative. + specQuota := make(map[string]v1alpha1.ResourceQuota, len(req.Resources)) + for resourceName, resQuota := range req.Resources { + if resQuota.Quota > math.MaxInt64 { + api.quotaError(w, http.StatusBadRequest, fmt.Sprintf("Quota value for resource %q exceeds int64 max", resourceName), startTime) + return + } + rq := v1alpha1.ResourceQuota{ + Quota: int64(resQuota.Quota), + } + if len(resQuota.PerAZ) > 0 { + rq.PerAZ = make(map[string]int64, len(resQuota.PerAZ)) + for az, azQuota := range resQuota.PerAZ { + if azQuota.Quota > math.MaxInt64 { + api.quotaError(w, http.StatusBadRequest, fmt.Sprintf("Quota value for resource %q in AZ %q exceeds int64 max", resourceName, az), startTime) + return + } + rq.PerAZ[string(az)] = int64(azQuota.Quota) + } + } + specQuota[string(resourceName)] = rq + } + + // Create or update ProjectQuota CRD + crdName := projectQuotaCRDName(projectID) + ctx := r.Context() + + var existing v1alpha1.ProjectQuota + err = api.client.Get(ctx, client.ObjectKey{Name: crdName}, &existing) + if err != nil { + if !apierrors.IsNotFound(err) { + // Real error + log.Error(err, "failed to get existing ProjectQuota", "name", crdName) + api.quotaError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to check existing quota: %v", err), startTime) + return + } + // Not found — create new + pq := &v1alpha1.ProjectQuota{ + ObjectMeta: metav1.ObjectMeta{ + Name: crdName, + }, + Spec: v1alpha1.ProjectQuotaSpec{ + ProjectID: projectID, + ProjectName: projectName, + DomainID: domainID, + DomainName: domainName, + Quota: specQuota, + }, + } + if err := api.client.Create(ctx, pq); err != nil { + log.Error(err, "failed to create ProjectQuota", "name", crdName) + api.quotaError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to create quota: %v", err), startTime) + return + } + log.V(1).Info("created ProjectQuota", "name", crdName, "projectID", projectID, "resources", len(specQuota)) + } else { + // Update existing + existing.Spec.Quota = specQuota + if projectName != "" { + existing.Spec.ProjectName = projectName + } + if domainID != "" { + existing.Spec.DomainID = domainID + } + if domainName != "" { + existing.Spec.DomainName = domainName + } + if err := api.client.Update(ctx, &existing); err != nil { + log.Error(err, "failed to update ProjectQuota", "name", crdName) + api.quotaError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to update quota: %v", err), startTime) + return + } + log.V(1).Info("updated ProjectQuota", "name", crdName, "projectID", projectID, "resources", len(specQuota)) + } // Return 204 No Content as expected by the LIQUID API w.WriteHeader(http.StatusNoContent) + api.recordQuotaMetrics(http.StatusNoContent, startTime) +} + +// quotaError writes an HTTP error response and records metrics. Used for error paths in HandleQuota. +func (api *HTTPAPI) quotaError(w http.ResponseWriter, statusCode int, msg string, startTime time.Time) { + http.Error(w, msg, statusCode) + api.recordQuotaMetrics(statusCode, startTime) +} + +// recordQuotaMetrics records Prometheus metrics for a quota API request. +func (api *HTTPAPI) recordQuotaMetrics(statusCode int, startTime time.Time) { + duration := time.Since(startTime).Seconds() + statusCodeStr := strconv.Itoa(statusCode) + api.quotaMonitor.requestCounter.WithLabelValues(statusCodeStr).Inc() + api.quotaMonitor.requestDuration.WithLabelValues(statusCodeStr).Observe(duration) } diff --git a/internal/scheduling/reservations/commitments/api/quota_monitor.go b/internal/scheduling/reservations/commitments/api/quota_monitor.go new file mode 100644 index 000000000..c06d4b788 --- /dev/null +++ b/internal/scheduling/reservations/commitments/api/quota_monitor.go @@ -0,0 +1,47 @@ +// Copyright SAP SE +// SPDX-License-Identifier: Apache-2.0 + +package api + +import "github.com/prometheus/client_golang/prometheus" + +// QuotaAPIMonitor provides metrics for the CR quota API. +type QuotaAPIMonitor struct { + requestCounter *prometheus.CounterVec + requestDuration *prometheus.HistogramVec +} + +// NewQuotaAPIMonitor creates a new monitor with Prometheus metrics. +// Metrics are pre-initialized with zero values for common HTTP status codes +// to ensure they appear in Prometheus before the first request. +func NewQuotaAPIMonitor() QuotaAPIMonitor { + m := QuotaAPIMonitor{ + requestCounter: prometheus.NewCounterVec(prometheus.CounterOpts{ + Name: "cortex_committed_resource_quota_api_requests_total", + Help: "Total number of quota API requests by status code.", + }, []string{"status_code"}), + requestDuration: prometheus.NewHistogramVec(prometheus.HistogramOpts{ + Name: "cortex_committed_resource_quota_api_request_duration_seconds", + Help: "Duration of quota API requests in seconds.", + Buckets: prometheus.DefBuckets, + }, []string{"status_code"}), + } + // Pre-initialize common status codes so they appear in Prometheus before the first request + for _, statusCode := range []string{"204", "400", "405", "500"} { + m.requestCounter.WithLabelValues(statusCode) + m.requestDuration.WithLabelValues(statusCode) + } + return m +} + +// Describe implements prometheus.Collector. +func (m *QuotaAPIMonitor) Describe(ch chan<- *prometheus.Desc) { + m.requestCounter.Describe(ch) + m.requestDuration.Describe(ch) +} + +// Collect implements prometheus.Collector. +func (m *QuotaAPIMonitor) Collect(ch chan<- prometheus.Metric) { + m.requestCounter.Collect(ch) + m.requestDuration.Collect(ch) +} diff --git a/internal/scheduling/reservations/commitments/api/quota_test.go b/internal/scheduling/reservations/commitments/api/quota_test.go new file mode 100644 index 000000000..218bc0815 --- /dev/null +++ b/internal/scheduling/reservations/commitments/api/quota_test.go @@ -0,0 +1,354 @@ +// Copyright SAP SE +// SPDX-License-Identifier: Apache-2.0 + +package api + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/cobaltcore-dev/cortex/api/v1alpha1" + commitments "github.com/cobaltcore-dev/cortex/internal/scheduling/reservations/commitments" + "github.com/majewsky/gg/option" + "github.com/sapcc/go-api-declarations/liquid" + "k8s.io/apimachinery/pkg/runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" +) + +// newTestScheme returns a scheme with v1alpha1 types registered. +func newTestScheme(t *testing.T) *runtime.Scheme { + t.Helper() + scheme := runtime.NewScheme() + if err := v1alpha1.AddToScheme(scheme); err != nil { + t.Fatalf("failed to add scheme: %v", err) + } + return scheme +} + +// marshalQuotaReq marshals a ServiceQuotaRequest, failing the test on error. +func marshalQuotaReq(t *testing.T, req liquid.ServiceQuotaRequest) []byte { + t.Helper() + body, err := json.Marshal(req) + if err != nil { + t.Fatalf("failed to marshal request: %v", err) + } + return body +} + +func TestHandleQuota_ErrorCases(t *testing.T) { + tests := []struct { + name string + method string + path string + body []byte + metadata *liquid.ProjectMetadata + enableQuota *bool // nil = default (enabled) + expectedStatus int + }{ + { + name: "MethodNotAllowed_GET", + method: http.MethodGet, + path: "/commitments/v1/projects/project-abc/quota", + body: nil, + expectedStatus: http.StatusMethodNotAllowed, + }, + { + name: "MethodNotAllowed_POST", + method: http.MethodPost, + path: "/commitments/v1/projects/project-abc/quota", + body: nil, + expectedStatus: http.StatusMethodNotAllowed, + }, + { + name: "DisabledAPI", + method: http.MethodPut, + path: "/commitments/v1/projects/project-abc/quota", + body: []byte(`{"resources":{}}`), + enableQuota: boolPtr(false), + expectedStatus: http.StatusServiceUnavailable, + }, + { + name: "InvalidBody", + method: http.MethodPut, + path: "/commitments/v1/projects/project-abc/quota", + body: []byte("{invalid"), + expectedStatus: http.StatusBadRequest, + }, + { + name: "EmptyBody", + method: http.MethodPut, + path: "/commitments/v1/projects/project-abc/quota", + body: []byte(""), + expectedStatus: http.StatusBadRequest, + }, + { + name: "UUIDMismatch", + method: http.MethodPut, + path: "/commitments/v1/projects/project-abc/quota", + metadata: &liquid.ProjectMetadata{ + UUID: "different-uuid", + Name: "my-project", + Domain: liquid.DomainMetadata{UUID: "domain-123", Name: "my-domain"}, + }, + expectedStatus: http.StatusBadRequest, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + scheme := newTestScheme(t) + k8sClient := fake.NewClientBuilder().WithScheme(scheme).Build() + + var httpAPI *HTTPAPI + if tc.enableQuota != nil && !*tc.enableQuota { + config := commitments.DefaultConfig() + config.EnableQuotaAPI = false + httpAPI = NewAPIWithConfig(k8sClient, config, nil) + } else { + httpAPI = NewAPI(k8sClient) + } + + // Build body: use provided bytes or construct from metadata + var bodyReader *bytes.Reader + switch { + case tc.body != nil: + bodyReader = bytes.NewReader(tc.body) + case tc.metadata != nil: + quotaReq := liquid.ServiceQuotaRequest{ + Resources: map[liquid.ResourceName]liquid.ResourceQuotaRequest{ + "hw_version_hana_1_ram": {Quota: 100}, + }, + } + quotaReq.ProjectMetadata = option.Some(*tc.metadata) + bodyReader = bytes.NewReader(marshalQuotaReq(t, quotaReq)) + default: + bodyReader = bytes.NewReader([]byte{}) + } + + req := httptest.NewRequest(tc.method, tc.path, bodyReader) + w := httptest.NewRecorder() + + httpAPI.HandleQuota(w, req) + + resp := w.Result() + defer resp.Body.Close() + + if resp.StatusCode != tc.expectedStatus { + t.Errorf("expected status %d, got %d", tc.expectedStatus, resp.StatusCode) + } + }) + } +} + +func TestHandleQuota_CreateAndUpdate(t *testing.T) { + tests := []struct { + name string + // existing is a pre-existing CRD to seed (nil = create, non-nil = update) + existing *v1alpha1.ProjectQuota + projectID string + resources map[liquid.ResourceName]liquid.ResourceQuotaRequest + metadata *liquid.ProjectMetadata + expectQuota map[string]int64 // resource name → expected total quota + expectPerAZ map[string]map[string]int64 // resource name → az → expected quota + expectName string + expectDomain string + expectDomName string + }{ + { + name: "Create_WithPerAZ", + projectID: "project-abc-123", + resources: map[liquid.ResourceName]liquid.ResourceQuotaRequest{ + "hw_version_hana_1_ram": { + Quota: 100, + PerAZ: map[liquid.AvailabilityZone]liquid.AZResourceQuotaRequest{ + "az-a": {Quota: 60}, + "az-b": {Quota: 40}, + }, + }, + }, + expectQuota: map[string]int64{"hw_version_hana_1_ram": 100}, + expectPerAZ: map[string]map[string]int64{ + "hw_version_hana_1_ram": {"az-a": 60, "az-b": 40}, + }, + }, + { + name: "Create_EmptyResources", + projectID: "project-empty", + resources: map[liquid.ResourceName]liquid.ResourceQuotaRequest{}, + expectQuota: map[string]int64{}, + }, + { + name: "Create_WithMetadata", + projectID: "project-meta-test", + resources: map[liquid.ResourceName]liquid.ResourceQuotaRequest{ + "hw_version_hana_1_ram": {Quota: 50}, + }, + metadata: &liquid.ProjectMetadata{ + UUID: "project-meta-test", + Name: "my-project-name", + Domain: liquid.DomainMetadata{ + UUID: "domain-uuid-456", + Name: "my-domain-name", + }, + }, + expectQuota: map[string]int64{"hw_version_hana_1_ram": 50}, + expectName: "my-project-name", + expectDomain: "domain-uuid-456", + expectDomName: "my-domain-name", + }, + { + name: "Update_QuotaValues", + existing: &v1alpha1.ProjectQuota{ + Spec: v1alpha1.ProjectQuotaSpec{ + ProjectID: "project-xyz", + DomainID: "original-domain", + DomainName: "original-domain-name", + ProjectName: "original-project-name", + Quota: map[string]v1alpha1.ResourceQuota{ + "hw_version_hana_1_ram": {Quota: 50, PerAZ: map[string]int64{"az-a": 50}}, + }, + }, + }, + projectID: "project-xyz", + resources: map[liquid.ResourceName]liquid.ResourceQuotaRequest{ + "hw_version_hana_1_ram": { + Quota: 200, + PerAZ: map[liquid.AvailabilityZone]liquid.AZResourceQuotaRequest{ + "az-a": {Quota: 120}, + "az-b": {Quota: 80}, + }, + }, + }, + expectQuota: map[string]int64{"hw_version_hana_1_ram": 200}, + expectPerAZ: map[string]map[string]int64{ + "hw_version_hana_1_ram": {"az-a": 120, "az-b": 80}, + }, + // Metadata should be preserved when not provided in update + expectDomain: "original-domain", + expectDomName: "original-domain-name", + expectName: "original-project-name", + }, + { + name: "Update_WithNewMetadata", + existing: &v1alpha1.ProjectQuota{ + Spec: v1alpha1.ProjectQuotaSpec{ + ProjectID: "project-update-meta", + DomainID: "old-domain", + DomainName: "old-domain-name", + ProjectName: "old-project-name", + Quota: map[string]v1alpha1.ResourceQuota{ + "hw_version_hana_1_ram": {Quota: 10}, + }, + }, + }, + projectID: "project-update-meta", + resources: map[liquid.ResourceName]liquid.ResourceQuotaRequest{ + "hw_version_hana_1_ram": {Quota: 99}, + }, + metadata: &liquid.ProjectMetadata{ + UUID: "project-update-meta", + Name: "new-project-name", + Domain: liquid.DomainMetadata{ + UUID: "new-domain", + Name: "new-domain-name", + }, + }, + expectQuota: map[string]int64{"hw_version_hana_1_ram": 99}, + expectName: "new-project-name", + expectDomain: "new-domain", + expectDomName: "new-domain-name", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + scheme := newTestScheme(t) + builder := fake.NewClientBuilder().WithScheme(scheme) + + if tc.existing != nil { + tc.existing.Name = projectQuotaCRDName(tc.projectID) + builder = builder.WithObjects(tc.existing) + } + k8sClient := builder.Build() + httpAPI := NewAPI(k8sClient) + + quotaReq := liquid.ServiceQuotaRequest{ + Resources: tc.resources, + } + if tc.metadata != nil { + quotaReq.ProjectMetadata = option.Some(*tc.metadata) + } + body := marshalQuotaReq(t, quotaReq) + + path := "/commitments/v1/projects/" + tc.projectID + "/quota" + req := httptest.NewRequest(http.MethodPut, path, bytes.NewReader(body)) + w := httptest.NewRecorder() + + httpAPI.HandleQuota(w, req) + + resp := w.Result() + defer resp.Body.Close() + + if resp.StatusCode != http.StatusNoContent { + t.Fatalf("expected status %d (No Content), got %d", http.StatusNoContent, resp.StatusCode) + } + + // Verify the ProjectQuota CRD + var pq v1alpha1.ProjectQuota + crdName := projectQuotaCRDName(tc.projectID) + if err := k8sClient.Get(context.Background(), client.ObjectKey{Name: crdName}, &pq); err != nil { + t.Fatalf("failed to get ProjectQuota CRD %q: %v", crdName, err) + } + + if pq.Spec.ProjectID != tc.projectID { + t.Errorf("expected ProjectID %q, got %q", tc.projectID, pq.Spec.ProjectID) + } + + // Verify quota totals + for resName, expectedTotal := range tc.expectQuota { + actual, ok := pq.Spec.Quota[resName] + if !ok { + t.Errorf("expected resource %q in quota spec", resName) + continue + } + if actual.Quota != expectedTotal { + t.Errorf("resource %q: expected quota %d, got %d", resName, expectedTotal, actual.Quota) + } + } + + // Verify per-AZ quotas + for resName, azMap := range tc.expectPerAZ { + actual, ok := pq.Spec.Quota[resName] + if !ok { + t.Errorf("expected resource %q in quota spec for per-AZ check", resName) + continue + } + for az, expectedAZ := range azMap { + if actual.PerAZ[az] != expectedAZ { + t.Errorf("resource %q AZ %q: expected %d, got %d", resName, az, expectedAZ, actual.PerAZ[az]) + } + } + } + + // Verify metadata + if tc.expectName != "" && pq.Spec.ProjectName != tc.expectName { + t.Errorf("expected ProjectName %q, got %q", tc.expectName, pq.Spec.ProjectName) + } + if tc.expectDomain != "" && pq.Spec.DomainID != tc.expectDomain { + t.Errorf("expected DomainID %q, got %q", tc.expectDomain, pq.Spec.DomainID) + } + if tc.expectDomName != "" && pq.Spec.DomainName != tc.expectDomName { + t.Errorf("expected DomainName %q, got %q", tc.expectDomName, pq.Spec.DomainName) + } + }) + } +} + +func boolPtr(b bool) *bool { + return &b +} diff --git a/internal/scheduling/reservations/commitments/config.go b/internal/scheduling/reservations/commitments/config.go index 888d37018..36c3ec00b 100644 --- a/internal/scheduling/reservations/commitments/config.go +++ b/internal/scheduling/reservations/commitments/config.go @@ -57,6 +57,11 @@ type Config struct { // When false, the endpoint will return HTTP 503 Service Unavailable. // This can be used as an emergency switch if the capacity reporting is causing issues. EnableReportCapacityAPI bool `json:"committedResourceEnableReportCapacityAPI"` + + // EnableQuotaAPI controls whether the quota API endpoint is active. + // When false, the endpoint will return HTTP 503 Service Unavailable. + // This can be used as an emergency switch if quota persistence is causing issues. + EnableQuotaAPI bool `json:"committedResourceEnableQuotaAPI"` } // ApplyDefaults fills in any unset values with defaults. @@ -103,5 +108,6 @@ func DefaultConfig() Config { EnableChangeCommitmentsAPI: true, EnableReportUsageAPI: true, EnableReportCapacityAPI: true, + EnableQuotaAPI: true, } } diff --git a/internal/scheduling/reservations/commitments/usage.go b/internal/scheduling/reservations/commitments/usage.go index d634fc2a0..14dbfa482 100644 --- a/internal/scheduling/reservations/commitments/usage.go +++ b/internal/scheduling/reservations/commitments/usage.go @@ -471,22 +471,33 @@ func (c *UsageCalculator) buildUsageResponse( } // Build ResourceUsageReport for all flavor groups (not just those with fixed ratio) - for flavorGroupName := range flavorGroups { + for flavorGroupName, groupData := range flavorGroups { // All flavor groups are included in usage reporting. // === 1. RAM Resource === 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 := FlavorGroupAcceptsCommitments(&groupData) for _, az := range allAZs { - ramPerAZ[az] = &liquid.AZResourceUsageReport{ + report := &liquid.AZResourceUsageReport{ Usage: 0, Subresources: []liquid.Subresource{}, } + if ramHasAZQuota { + report.Quota = Some(int64(-1)) // infinite — will be overridden by ProjectQuota CRD + } + ramPerAZ[az] = report } if azData, exists := usageByFlavorGroupAZ[flavorGroupName]; exists { for az, data := range azData { if _, known := ramPerAZ[az]; !known { - ramPerAZ[az] = &liquid.AZResourceUsageReport{} + report := &liquid.AZResourceUsageReport{} + if ramHasAZQuota { + report.Quota = Some(int64(-1)) + } + ramPerAZ[az] = report } ramPerAZ[az].Usage = data.ramUsage ramPerAZ[az].PhysicalUsage = Some(data.ramUsage) // No overcommit for RAM