Repository: cloudnative-pg/cloudnative-pg

Issue:
[Bug]: certificate alternative dns names have wrong domain
### 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.25 (latest patch)

### What version of Kubernetes are you using?

1.32

### What is your Kubernetes environment?

Other

### How did you install the operator?

Helm

### What happened?

I just stumbled upon the certificates in the `status` of my `cluster`:
```yaml
status:
  certificates:
    serverAltDNSNames:
    - postgresql-rw
    - postgresql-rw.postgresql
    - postgresql-rw.postgresql.svc
    - postgresql-rw.postgresql.svc.cluster.local
    - postgresql-r
    - postgresql-r.postgresql
    - postgresql-r.postgresql.svc
    - postgresql-r.postgresql.svc.cluster.local
    - postgresql-ro
    - postgresql-ro.postgresql
    - postgresql-ro.postgresql.svc
    - postgresql-ro.postgresql.svc.cluster.local
```

The thing that made me wonder is: my kubernetes domain is not `cluster.local`.
So I went into the code and figured that function `GetClusterAltDNSNames` returns the dns names with `cluster.local` hard-coded.

I know that `cluster.local` is the default domain of kubernetes clusters, but can be altered to more or less anything.
So in my opinion, that should at least be configurable.


### Cluster resource

```shell
apiVersion: postgresql.cnpg.io/v1
kind: Cluster
metadata:
  name: postgresql
  namespace: postgresql
spec:
# irrelevant
```

### Relevant log output

```shell

```

### Code of Conduct

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

Discussion:

Solution patch:
diff --git a/.wordlist-en-custom.txt b/.wordlist-en-custom.txt
index 35211d21..eca5fe59 100644
--- a/.wordlist-en-custom.txt
+++ b/.wordlist-en-custom.txt
@@ -161,6 +161,7 @@ ExtensionSpec
 ExtensionStatus
 ExternalCluster
 FQDN
+FQDNs
 Fei
 Filesystem
 Fluentd
@@ -213,6 +214,7 @@ KinD
 Krew
 KubeCon
 Kubegres
+KubernetesClusterDomain
 Kumar
 LDAP
 LDAPBindAsAuth
@@ -910,6 +912,7 @@ kubebuilder
 kubectl
 kubelet
 kubernetes
+kubernetesClusterDomain
 labelColumnName
 labelColumnValue
 labelName
diff --git a/api/v1/cluster_funcs.go b/api/v1/cluster_funcs.go
index 340ce9e7..9e493829 100644
--- a/api/v1/cluster_funcs.go
+++ b/api/v1/cluster_funcs.go
@@ -1042,7 +1042,7 @@ func (cluster *Cluster) GetClusterAltDNSNames() []string {
 			serviceName,
 			fmt.Sprintf("%v.%v", serviceName, cluster.Namespace),
 			fmt.Sprintf("%v.%v.svc", serviceName, cluster.Namespace),
-			fmt.Sprintf("%v.%v.svc.cluster.local", serviceName, cluster.Namespace),
+			fmt.Sprintf("%v.%v.svc.%s", serviceName, cluster.Namespace, configuration.Current.KubernetesClusterDomain),
 		}
 	}
 	altDNSNames := slices.Concat(
diff --git a/docs/src/certificates.md b/docs/src/certificates.md
index c9cc2eb9..70108140 100644
--- a/docs/src/certificates.md
+++ b/docs/src/certificates.md
@@ -50,6 +50,11 @@ expiration (within a 90-day validity period).
     certificates not controlled by CloudNativePG must be re-issued following the
     renewal process.
 
+When generating certificates, the operator assumes that the Kubernetes
+cluster's DNS zone is set to `cluster.local` by default. This behavior can be
+customized by setting the `KUBERNETES_CLUSTER_DOMAIN` environment variable. A
+convenient alternative is to use the [operator's configuration capability](operator_conf.md).
+
 ### Server certificates
 
 #### Server CA secret
diff --git a/docs/src/operator_conf.md b/docs/src/operator_conf.md
index 70c47c84..c5895b2e 100644
--- a/docs/src/operator_conf.md
+++ b/docs/src/operator_conf.md
@@ -45,6 +45,7 @@ Name | Description
 `INHERITED_ANNOTATIONS` | List of annotation names that, when defined in a `Cluster` metadata, will be inherited by all the generated resources, including pods
 `INHERITED_LABELS` | List of label names that, when defined in a `Cluster` metadata, will be inherited by all the generated resources, including pods
 `INSTANCES_ROLLOUT_DELAY` | The duration (in seconds) to wait between roll-outs of individual PostgreSQL instances within the same cluster during an operator upgrade. The default value is `0`, meaning no delay between upgrades of instances in the same PostgreSQL cluster.
+`KUBERNETES_CLUSTER_DOMAIN` | Defines the domain suffix for service FQDNs within the Kubernetes cluster. If left unset, it defaults to "cluster.local".
 `MONITORING_QUERIES_CONFIGMAP` | The name of a ConfigMap in the operator's namespace with a set of default queries (to be specified under the key `queries`) to be applied to all created Clusters
 `MONITORING_QUERIES_SECRET` | The name of a Secret in the operator's namespace with a set of default queries (to be specified under the key `queries`) to be applied to all created Clusters
 `PULL_SECRET_NAME` | Name of an additional pull secret to be defined in the operator's namespace and to be used to download images
diff --git a/docs/src/release_notes/v1.26.md b/docs/src/release_notes/v1.26.md
index 17f9f6b6..37949438 100644
--- a/docs/src/release_notes/v1.26.md
+++ b/docs/src/release_notes/v1.26.md
@@ -39,6 +39,11 @@ on the release branch in GitHub.
   which, if specified, sets the `tcp_user_timeout` parameter on all standby
   instances managed by the operator.
 
+- Added the `KUBERNETES_CLUSTER_DOMAIN` configuration option to the operator,
+  allowing users to specify the domain suffix for fully qualified domain names
+  (FQDNs) generated within the Kubernetes cluster. If not set, it defaults to
+  `cluster.local`. (#6989)
+
 - feat: support customizable pod patches via annotations (#6323)
 
 - `cnpg` plugin updates:
diff --git a/internal/configuration/configuration.go b/internal/configuration/configuration.go
index 6e2bc703..56f00fa2 100644
--- a/internal/configuration/configuration.go
+++ b/internal/configuration/configuration.go
@@ -40,6 +40,10 @@ const (
 
 	// ExpiringCheckThreshold is the default threshold to consider a certificate as expiring
 	ExpiringCheckThreshold = 7
+
+	// DefaultKubernetesClusterDomain is the default value used as
+	// Kubernetes cluster domain.
+	DefaultKubernetesClusterDomain = "cluster.local"
 )
 
 // DefaultPluginSocketDir is the default directory where the plugin sockets are located.
@@ -133,6 +137,10 @@ type Data struct {
 	// string, which is used by the standby server to connect to the
 	// primary server in CloudNativePG.
 	StandbyTCPUserTimeout int `json:"standbyTcpUserTimeout" env:"STANDBY_TCP_USER_TIMEOUT"`
+
+	// KubernetesClusterDomain defines the domain suffix for service FQDNs
+	// within the Kubernetes cluster. If left unset, it defaults to `cluster.local`.
+	KubernetesClusterDomain string `json:"kubernetesClusterDomain" env:"KUBERNETES_CLUSTER_DOMAIN"`
 }
 
 // Current is the configuration used by the operator
@@ -141,14 +149,15 @@ var Current = NewConfiguration()
 // newDefaultConfig creates a configuration holding the defaults
 func newDefaultConfig() *Data {
 	return &Data{
-		OperatorPullSecretName: DefaultOperatorPullSecretName,
-		OperatorImageName:      versions.DefaultOperatorImageName,
-		PostgresImageName:      versions.DefaultImageName,
-		PluginSocketDir:        DefaultPluginSocketDir,
-		CreateAnyService:       false,
-		CertificateDuration:    CertificateDuration,
-		ExpiringCheckThreshold: ExpiringCheckThreshold,
-		StandbyTCPUserTimeout:  0,
+		OperatorPullSecretName:  DefaultOperatorPullSecretName,
+		OperatorImageName:       versions.DefaultOperatorImageName,
+		PostgresImageName:       versions.DefaultImageName,
+		PluginSocketDir:         DefaultPluginSocketDir,
+		CreateAnyService:        false,
+		CertificateDuration:     CertificateDuration,
+		ExpiringCheckThreshold:  ExpiringCheckThreshold,
+		StandbyTCPUserTimeout:   0,
+		KubernetesClusterDomain: DefaultKubernetesClusterDomain,
 	}
 }
 



Test patch:
diff --git a/api/v1/cluster_funcs_test.go b/api/v1/cluster_funcs_test.go
index fd2d93e5..1c31cde6 100644
--- a/api/v1/cluster_funcs_test.go
+++ b/api/v1/cluster_funcs_test.go
@@ -461,12 +461,12 @@ var _ = Describe("look up for secrets", Ordered, func() {
 	}
 
 	// assertServiceNamesPresent returns the first missing service name encountered
-	assertServiceNamesPresent := func(data *stringset.Data, serviceName string) string {
+	assertServiceNamesPresent := func(data *stringset.Data, serviceName string, clusterDomain string) string {
 		assertions := []string{
 			serviceName,
 			fmt.Sprintf("%v.%v", serviceName, cluster.Namespace),
 			fmt.Sprintf("%v.%v.svc", serviceName, cluster.Namespace),
-			fmt.Sprintf("%v.%v.svc.cluster.local", serviceName, cluster.Namespace),
+			fmt.Sprintf("%v.%v.svc.%s", serviceName, cluster.Namespace, clusterDomain),
 		}
 		for _, assertion := range assertions {
 			if !data.Has(assertion) {
@@ -498,11 +498,11 @@ var _ = Describe("look up for secrets", Ordered, func() {
 		Expect(names).To(HaveLen(12))
 		namesSet := stringset.From(names)
 		Expect(namesSet.Len()).To(Equal(12))
-		Expect(assertServiceNamesPresent(namesSet, cluster.GetServiceReadWriteName())).To(BeEmpty(),
+		Expect(assertServiceNamesPresent(namesSet, cluster.GetServiceReadWriteName(), "cluster.local")).To(BeEmpty(),
 			"missing service name")
-		Expect(assertServiceNamesPresent(namesSet, cluster.GetServiceReadName())).To(BeEmpty(),
+		Expect(assertServiceNamesPresent(namesSet, cluster.GetServiceReadName(), "cluster.local")).To(BeEmpty(),
 			"missing service name")
-		Expect(assertServiceNamesPresent(namesSet, cluster.GetServiceReadOnlyName())).To(BeEmpty(),
+		Expect(assertServiceNamesPresent(namesSet, cluster.GetServiceReadOnlyName(), "cluster.local")).To(BeEmpty(),
 			"missing service name")
 	})
 
@@ -521,9 +521,9 @@ var _ = Describe("look up for secrets", Ordered, func() {
 		It("should generate correctly the managed services names", func() {
 			namesSet := stringset.From(cluster.GetClusterAltDNSNames())
 			Expect(namesSet.Len()).To(Equal(20))
-			Expect(assertServiceNamesPresent(namesSet, "one")).To(BeEmpty(),
+			Expect(assertServiceNamesPresent(namesSet, "one", "cluster.local")).To(BeEmpty(),
 				"missing service name")
-			Expect(assertServiceNamesPresent(namesSet, "two")).To(BeEmpty(),
+			Expect(assertServiceNamesPresent(namesSet, "two", "cluster.local")).To(BeEmpty(),
 				"missing service name")
 		})
 
@@ -536,9 +536,9 @@ var _ = Describe("look up for secrets", Ordered, func() {
 			Expect(namesSet.Len()).To(Equal(12))
 			Expect(namesSet.Has(cluster.GetServiceReadName())).To(BeFalse())
 			Expect(namesSet.Has(cluster.GetServiceReadOnlyName())).To(BeFalse())
-			Expect(assertServiceNamesPresent(namesSet, "one")).To(BeEmpty(),
+			Expect(assertServiceNamesPresent(namesSet, "one", "cluster.local")).To(BeEmpty(),
 				"missing service name")
-			Expect(assertServiceNamesPresent(namesSet, "two")).To(BeEmpty(),
+			Expect(assertServiceNamesPresent(namesSet, "two", "cluster.local")).To(BeEmpty(),
 				"missing service name")
 		})
 	})