Repository: cloudnative-pg/cloudnative-pg

Issue:
[Bug]: Cannot create cluster because wrong resource limit validation
### Is there an existing issue already for this bug?

- [x] I have searched for an existing issue, and could not find anything. I believe this is a new bug.

### I have read the troubleshooting guide

- [x] I have read the troubleshooting guide and I think this is a new bug.

### I am running a supported version of CloudNativePG

- [x] I have read the troubleshooting guide and I think this is a new bug.

### Contact Details

_No response_

### Version

1.26 (latest patch)

### What version of Kubernetes are you using?

1.31

### What is your Kubernetes environment?

Self-managed: kind (evaluation)

### How did you install the operator?

YAML manifest

### What happened?

The operator refuse to create the cluster despite having declared enough resources:


Cluster resource declarations:
```yaml
      resources:
        requests:
          cpu: 8
          memory: 64Gi
        limits:
          cpu: 16
          memory: 64Gi
          hugepages-1Gi: 128Gi
```

Cluster pg_conf:
```yaml
        huge_pages: on
        huge_page_size: 1GB
        shared_buffers: 128GB
```

But the operator refuse to create the pod with the following error message:

` Invalid value: "64Gi": Memory request is lower than PostgreSQL `shared_buffers` value`

As the shared buffer are required to use huge pages (huge_pages: on) there is no reason to enforce a memory limit higher than shared_buffer request.



### Code of Conduct

- [x] I agree to follow this project's Code of Conduct

Discussion:
@dosubot: <!-- Greeting -->
Hi @Jean-Daniel! I'm [Dosu](https://go.dosu.dev/dosubot) and I’m helping the cloudnative-pg team.

<!-- Answer -->
This error comes from CloudNativePG's validation logic, which checks that the Kubernetes memory request is at least as large as the PostgreSQL `shared_buffers` setting. If `shared_buffers` is set higher than the memory request, the operator blocks the cluster creation with the error you saw. This is intended to ensure that PostgreSQL doesn't try to allocate more memory for shared buffers than the pod is guaranteed to have available, which could otherwise lead to OOM kills or instability.

However, your use of huge pages (with `huge_pages: on` and `huge_page_size: 1GB`) introduces a nuance: the memory for `shared_buffers` is allocated from huge pages, not regular memory, so the validation logic doesn't account for this special case. As a result, it still enforces the rule that `requests.memory` must be at least as large as `shared_buffers`, even though huge pages are separately allocated and requested via the `hugepages-1Gi` resource.

There are no existing issues or PRs addressing this specific scenario. The documentation recommends setting memory requests and limits to match your PostgreSQL memory parameters for a "Guaranteed" QoS class, but it doesn't cover the huge pages edge case you described ([resource management docs](https://cloudnative-pg.io/documentation/current/resource_management)).

As a workaround, you can set your `requests.memory` to match or exceed `shared_buffers` (e.g., 128Gi), even though this is redundant when using huge pages. This will satisfy the validation logic and allow the cluster to be created, but it does mean you'll be requesting more regular memory from Kubernetes than is actually needed for your workload.

If you want this behavior to be improved (i.e., for the validation to recognize huge pages and not require `requests.memory` to match `shared_buffers` in this case), it would require a change in the operator's validation logic. For now, the only way to proceed is to align `requests.memory` with `shared_buffers` as a workaround.


<!-- Dosu Comment Footer -->
*To reply, just mention [@dosu](https://go.dosu.dev/dosubot).*

---
<sup>How did I do? [Good](https://app.dosu.dev/response-feedback/a9d950b0-15a8-448c-8efe-be398fdd78db?feedback_type=great_response) | [Irrelevant](https://app.dosu.dev/response-feedback/a9d950b0-15a8-448c-8efe-be398fdd78db?feedback_type=irrelevant_answer) | [Incorrect](https://app.dosu.dev/response-feedback/a9d950b0-15a8-448c-8efe-be398fdd78db?feedback_type=incorrect_sources) | [Verbose](https://app.dosu.dev/response-feedback/a9d950b0-15a8-448c-8efe-be398fdd78db?feedback_type=too_verbose) | [Hallucination](https://app.dosu.dev/response-feedback/a9d950b0-15a8-448c-8efe-be398fdd78db?feedback_type=hallucination) | [Report 🐛](https://app.dosu.dev/response-feedback/a9d950b0-15a8-448c-8efe-be398fdd78db?feedback_type=bug_report) | [Other](https://app.dosu.dev/response-feedback/a9d950b0-15a8-448c-8efe-be398fdd78db?feedback_type=other)</sup>&nbsp;&nbsp;[![Join Discord](https://img.shields.io/badge/join-5865F2?logo=discord&logoColor=white&label=)](https://go.dosu.dev/discord-bot)&nbsp;[![Share on X](https://img.shields.io/badge/X-share-black)](https://twitter.com/intent/tweet?text=%40dosu_ai%20helped%20me%20solve%20this%20issue!&url=https%3A//github.com/cloudnative-pg/cloudnative-pg/issues/7862)

Solution patch:
diff --git a/internal/webhook/v1/cluster_webhook.go b/internal/webhook/v1/cluster_webhook.go
index ca149dc0..e9fd7678 100644
--- a/internal/webhook/v1/cluster_webhook.go
+++ b/internal/webhook/v1/cluster_webhook.go
@@ -859,45 +859,55 @@ func (v *ClusterCustomValidator) validateImagePullPolicy(r *apiv1.Cluster) field
 func (v *ClusterCustomValidator) validateResources(r *apiv1.Cluster) field.ErrorList {
 	var result field.ErrorList
 
-	cpuRequest := r.Spec.Resources.Requests.Cpu()
+	cpuRequests := r.Spec.Resources.Requests.Cpu()
 	cpuLimits := r.Spec.Resources.Limits.Cpu()
-	if !cpuRequest.IsZero() && !cpuLimits.IsZero() {
-		cpuRequestGtThanLimit := cpuRequest.Cmp(*cpuLimits) > 0
+	if !cpuRequests.IsZero() && !cpuLimits.IsZero() {
+		cpuRequestGtThanLimit := cpuRequests.Cmp(*cpuLimits) > 0
 		if cpuRequestGtThanLimit {
 			result = append(result, field.Invalid(
 				field.NewPath("spec", "resources", "requests", "cpu"),
-				cpuRequest.String(),
+				cpuRequests.String(),
 				"CPU request is greater than the limit",
 			))
 		}
 	}
 
-	memoryRequest := r.Spec.Resources.Requests.Memory()
-	rawSharedBuffer := r.Spec.PostgresConfiguration.Parameters[sharedBuffersParameter]
-	if !memoryRequest.IsZero() && rawSharedBuffer != "" {
-		if sharedBuffers, err := parsePostgresQuantityValue(rawSharedBuffer); err == nil {
-			if memoryRequest.Cmp(sharedBuffers) < 0 {
-				result = append(result, field.Invalid(
-					field.NewPath("spec", "resources", "requests", "memory"),
-					memoryRequest.String(),
-					"Memory request is lower than PostgreSQL `shared_buffers` value",
-				))
-			}
-		}
-	}
-
+	memoryRequests := r.Spec.Resources.Requests.Memory()
 	memoryLimits := r.Spec.Resources.Limits.Memory()
-	if !memoryRequest.IsZero() && !memoryLimits.IsZero() {
-		memoryRequestGtThanLimit := memoryRequest.Cmp(*memoryLimits) > 0
+	if !memoryRequests.IsZero() && !memoryLimits.IsZero() {
+		memoryRequestGtThanLimit := memoryRequests.Cmp(*memoryLimits) > 0
 		if memoryRequestGtThanLimit {
 			result = append(result, field.Invalid(
 				field.NewPath("spec", "resources", "requests", "memory"),
-				memoryRequest.String(),
+				memoryRequests.String(),
 				"Memory request is greater than the limit",
 			))
 		}
 	}
 
+	hugePages, hugePagesErrors := validateHugePagesResources(r)
+	result = append(result, hugePagesErrors...)
+	if cpuRequests.IsZero() && cpuLimits.IsZero() && memoryRequests.IsZero() && memoryLimits.IsZero() &&
+		len(hugePages) > 0 {
+		result = append(result, field.Forbidden(
+			field.NewPath("spec", "resources"),
+			"HugePages require cpu or memory",
+		))
+	}
+
+	rawSharedBuffer := r.Spec.PostgresConfiguration.Parameters[sharedBuffersParameter]
+	if rawSharedBuffer != "" {
+		if sharedBuffers, err := parsePostgresQuantityValue(rawSharedBuffer); err == nil {
+			if !hasEnoughMemoryForSharedBuffers(sharedBuffers, memoryRequests, hugePages) {
+				result = append(result, field.Invalid(
+					field.NewPath("spec", "resources", "requests"),
+					memoryRequests.String(),
+					"Memory request is lower than PostgreSQL `shared_buffers` value",
+				))
+			}
+		}
+	}
+
 	ephemeralStorageRequest := r.Spec.Resources.Requests.StorageEphemeral()
 	ephemeralStorageLimits := r.Spec.Resources.Limits.StorageEphemeral()
 	if !ephemeralStorageRequest.IsZero() && !ephemeralStorageLimits.IsZero() {
@@ -914,6 +924,50 @@ func (v *ClusterCustomValidator) validateResources(r *apiv1.Cluster) field.Error
 	return result
 }
 
+func validateHugePagesResources(r *apiv1.Cluster) (map[corev1.ResourceName]resource.Quantity, field.ErrorList) {
+	var result field.ErrorList
+	hugepages := make(map[corev1.ResourceName]resource.Quantity)
+	for name, quantity := range r.Spec.Resources.Limits {
+		if strings.HasPrefix(string(name), corev1.ResourceHugePagesPrefix) {
+			hugepages[name] = quantity
+		}
+	}
+	for name, quantity := range r.Spec.Resources.Requests {
+		if strings.HasPrefix(string(name), corev1.ResourceHugePagesPrefix) {
+			if existingQuantity, exists := hugepages[name]; exists {
+				if existingQuantity.Cmp(quantity) != 0 {
+					result = append(result, field.Invalid(
+						field.NewPath("spec", "resources", "requests", string(name)),
+						quantity.String(),
+						"HugePages requests must equal the limits",
+					))
+				}
+				continue
+			}
+			hugepages[name] = quantity
+		}
+	}
+	return hugepages, result
+}
+
+func hasEnoughMemoryForSharedBuffers(
+	sharedBuffers resource.Quantity,
+	memoryRequest *resource.Quantity,
+	hugePages map[corev1.ResourceName]resource.Quantity,
+) bool {
+	if memoryRequest.IsZero() || sharedBuffers.Cmp(*memoryRequest) <= 0 {
+		return true
+	}
+
+	for _, quantity := range hugePages {
+		if sharedBuffers.Cmp(quantity) <= 0 {
+			return true
+		}
+	}
+
+	return false
+}
+
 func (v *ClusterCustomValidator) validateSynchronousReplicaConfiguration(r *apiv1.Cluster) field.ErrorList {
 	if r.Spec.PostgresConfiguration.Synchronous == nil {
 		return nil



Test patch:
diff --git a/internal/webhook/v1/cluster_webhook_test.go b/internal/webhook/v1/cluster_webhook_test.go
index 5d21839e..3c62c700 100644
--- a/internal/webhook/v1/cluster_webhook_test.go
+++ b/internal/webhook/v1/cluster_webhook_test.go
@@ -4508,6 +4508,12 @@ var _ = Describe("validateResources", func() {
 		Expect(errors[0].Detail).To(Equal("Memory request is lower than PostgreSQL `shared_buffers` value"))
 	})
 
+	It("returns no errors when no memoryRequest is set", func() {
+		cluster.Spec.PostgresConfiguration.Parameters["shared_buffers"] = "1GB"
+		errors := v.validateResources(cluster)
+		Expect(errors).To(BeEmpty())
+	})
+
 	It("returns no errors when memoryRequest is greater than or equal to shared_buffers in GB", func() {
 		cluster.Spec.Resources.Requests["memory"] = resource.MustParse("2Gi")
 		cluster.Spec.PostgresConfiguration.Parameters["shared_buffers"] = "1GB"
@@ -4515,6 +4521,48 @@ var _ = Describe("validateResources", func() {
 		Expect(errors).To(BeEmpty())
 	})
 
+	It("returns an error when hugepages request is different than hugepages limits", func() {
+		cluster.Spec.Resources.Requests["memory"] = resource.MustParse("2Gi")
+		cluster.Spec.Resources.Requests["hugepages-1Gi"] = resource.MustParse("1Gi")
+		cluster.Spec.Resources.Limits["hugepages-1Gi"] = resource.MustParse("2Gi")
+		errors := v.validateResources(cluster)
+		Expect(errors).To(HaveLen(1))
+		Expect(errors[0].Detail).To(Equal("HugePages requests must equal the limits"))
+	})
+
+	It("returns an error when hugepages request is present but no CPU or memory are", func() {
+		cluster.Spec.Resources.Requests["hugepages-1Gi"] = resource.MustParse("1Gi")
+		errors := v.validateResources(cluster)
+		Expect(errors).To(HaveLen(1))
+		Expect(errors[0].Detail).To(Equal("HugePages require cpu or memory"))
+	})
+
+	It("returns an error when no request is enough to contain shared_buffers, even if the sum is", func() {
+		cluster.Spec.Resources.Requests["memory"] = resource.MustParse("1Gi")
+		cluster.Spec.Resources.Requests["ugepages-1Gi"] = resource.MustParse("1Gi")
+		cluster.Spec.Resources.Requests["hugepages-2Mi"] = resource.MustParse("1Gi")
+		cluster.Spec.PostgresConfiguration.Parameters["shared_buffers"] = "2000000kB"
+		errors := v.validateResources(cluster)
+		Expect(errors).To(HaveLen(1))
+		Expect(errors[0].Detail).To(Equal("Memory request is lower than PostgreSQL `shared_buffers` value"))
+	})
+
+	It("returns no errors when hugepages-1Gi request is greater than or equal to shared_buffers in GB", func() {
+		cluster.Spec.Resources.Requests["memory"] = resource.MustParse("256Mi")
+		cluster.Spec.Resources.Requests["hugepages-1Gi"] = resource.MustParse("1Gi")
+		cluster.Spec.PostgresConfiguration.Parameters["shared_buffers"] = "1GB"
+		errors := v.validateResources(cluster)
+		Expect(errors).To(BeEmpty())
+	})
+
+	It("returns no errors when hugepages-2Mi request is greater than or equal to shared_buffers in GB", func() {
+		cluster.Spec.Resources.Requests["memory"] = resource.MustParse("256Mi")
+		cluster.Spec.Resources.Limits["hugepages-2Mi"] = resource.MustParse("1Gi")
+		cluster.Spec.PostgresConfiguration.Parameters["shared_buffers"] = "1GB"
+		errors := v.validateResources(cluster)
+		Expect(errors).To(BeEmpty())
+	})
+
 	It("returns no errors when shared_buffers is in a format that can't be parsed", func() {
 		cluster.Spec.Resources.Requests["memory"] = resource.MustParse("1Gi")
 		cluster.Spec.PostgresConfiguration.Parameters["shared_buffers"] = "invalid_value"