Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions percona/controller/pgcluster/controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -599,7 +599,7 @@ func (r *PGClusterReconciler) reconcileEnvFromSecrets(ctx context.Context, cr *v
m[&set.EnvFrom] = set.Metadata
}

if len(cr.Spec.Proxy.PGBouncer.EnvFrom) > 0 {
if cr.Spec.Proxy.IsSet() && len(cr.Spec.Proxy.PGBouncer.EnvFrom) > 0 {
if cr.Spec.Proxy.PGBouncer.Metadata == nil {
cr.Spec.Proxy.PGBouncer.Metadata = new(v1beta1.Metadata)
}
Expand All @@ -608,7 +608,7 @@ func (r *PGClusterReconciler) reconcileEnvFromSecrets(ctx context.Context, cr *v

if len(cr.Spec.Backups.PGBackRest.EnvFrom) > 0 {
if cr.Spec.Backups.PGBackRest.Metadata == nil {
cr.Spec.Proxy.PGBouncer.Metadata = new(v1beta1.Metadata)
cr.Spec.Backups.PGBackRest.Metadata = new(v1beta1.Metadata)
}
m[&cr.Spec.Backups.PGBackRest.EnvFrom] = cr.Spec.Backups.PGBackRest.Metadata
}
Expand Down
188 changes: 188 additions & 0 deletions percona/controller/pgcluster/gethost_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
package pgcluster

import (
"context"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/client-go/kubernetes/scheme"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/client/fake"

v2 "github.com/percona/percona-postgresql-operator/v2/pkg/apis/pgv2.percona.com/v2"
"github.com/percona/percona-postgresql-operator/v2/pkg/apis/postgres-operator.crunchydata.com/v1beta1"
)

func TestGetHost(t *testing.T) {
ctx := context.Background()

const (
clusterName = "test-cluster"
ns = "test-ns"
)

tests := []struct {
name string
proxy *v2.PGProxySpec
svc *corev1.Service
expectedHost string
expectErr bool
}{
{
name: "proxy is nil",
proxy: nil,
expectedHost: clusterName + "-primary." + ns + ".svc",
},
{
name: "proxy set but PGBouncer is nil",
proxy: &v2.PGProxySpec{PGBouncer: nil},
expectedHost: clusterName + "-primary." + ns + ".svc",
},
{
name: "PGBouncer configured, no ServiceExpose",
proxy: &v2.PGProxySpec{PGBouncer: &v2.PGBouncerSpec{}},
expectedHost: clusterName + "-pgbouncer." + ns + ".svc",
},
{
name: "PGBouncer configured, ServiceExpose is ClusterIP",
proxy: &v2.PGProxySpec{PGBouncer: &v2.PGBouncerSpec{
ServiceExpose: &v2.ServiceExpose{Type: string(corev1.ServiceTypeClusterIP)},
}},
expectedHost: clusterName + "-pgbouncer." + ns + ".svc",
},
{
name: "PGBouncer configured, ServiceExpose is NodePort",
proxy: &v2.PGProxySpec{PGBouncer: &v2.PGBouncerSpec{
ServiceExpose: &v2.ServiceExpose{Type: string(corev1.ServiceTypeNodePort)},
}},
expectedHost: clusterName + "-pgbouncer." + ns + ".svc",
},
{
name: "PGBouncer LoadBalancer with IP",
proxy: &v2.PGProxySpec{PGBouncer: &v2.PGBouncerSpec{
ServiceExpose: &v2.ServiceExpose{Type: string(corev1.ServiceTypeLoadBalancer)},
}},
svc: &corev1.Service{
ObjectMeta: metav1.ObjectMeta{
Name: clusterName + "-pgbouncer",
Namespace: ns,
},
Status: corev1.ServiceStatus{
LoadBalancer: corev1.LoadBalancerStatus{
Ingress: []corev1.LoadBalancerIngress{
{IP: "10.0.0.1"},
},
},
},
},
expectedHost: "10.0.0.1",
},
{
name: "PGBouncer LoadBalancer with hostname",
proxy: &v2.PGProxySpec{PGBouncer: &v2.PGBouncerSpec{
ServiceExpose: &v2.ServiceExpose{Type: string(corev1.ServiceTypeLoadBalancer)},
}},
svc: &corev1.Service{
ObjectMeta: metav1.ObjectMeta{
Name: clusterName + "-pgbouncer",
Namespace: ns,
},
Status: corev1.ServiceStatus{
LoadBalancer: corev1.LoadBalancerStatus{
Ingress: []corev1.LoadBalancerIngress{
{Hostname: "my-lb.example.com"},
},
},
},
},
expectedHost: "my-lb.example.com",
},
{
name: "PGBouncer LoadBalancer with both IP and hostname prefers hostname",
proxy: &v2.PGProxySpec{PGBouncer: &v2.PGBouncerSpec{
ServiceExpose: &v2.ServiceExpose{Type: string(corev1.ServiceTypeLoadBalancer)},
}},
svc: &corev1.Service{
ObjectMeta: metav1.ObjectMeta{
Name: clusterName + "-pgbouncer",
Namespace: ns,
},
Status: corev1.ServiceStatus{
LoadBalancer: corev1.LoadBalancerStatus{
Ingress: []corev1.LoadBalancerIngress{
{IP: "10.0.0.1", Hostname: "my-lb.example.com"},
},
},
},
},
expectedHost: "my-lb.example.com",
},
{
name: "PGBouncer LoadBalancer with no ingress returns empty host",
proxy: &v2.PGProxySpec{PGBouncer: &v2.PGBouncerSpec{
ServiceExpose: &v2.ServiceExpose{Type: string(corev1.ServiceTypeLoadBalancer)},
}},
svc: &corev1.Service{
ObjectMeta: metav1.ObjectMeta{
Name: clusterName + "-pgbouncer",
Namespace: ns,
},
},
expectedHost: "",
},
{
name: "PGBouncer LoadBalancer service not found",
proxy: &v2.PGProxySpec{PGBouncer: &v2.PGBouncerSpec{
ServiceExpose: &v2.ServiceExpose{Type: string(corev1.ServiceTypeLoadBalancer)},
}},
expectErr: true,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cr := &v2.PerconaPGCluster{
ObjectMeta: metav1.ObjectMeta{
Name: clusterName,
Namespace: ns,
},
Spec: v2.PerconaPGClusterSpec{
Proxy: tt.proxy,
},
}

var objs []client.Object
if tt.svc != nil {
objs = append(objs, tt.svc)
}

s := scheme.Scheme
err := v1beta1.AddToScheme(s)
require.NoError(t, err)
err = v2.AddToScheme(s)
require.NoError(t, err)

cl := fake.NewClientBuilder().
WithScheme(s).
WithObjects(objs...).
WithStatusSubresource(objs...).
Build()

r := &PGClusterReconciler{
Client: cl,
}

host, err := r.getHost(ctx, cr)
if tt.expectErr {
require.Error(t, err)
return
}

require.NoError(t, err)
assert.Equal(t, tt.expectedHost, host)
})
}
}
24 changes: 21 additions & 3 deletions percona/controller/pgcluster/status.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package pgcluster

import (
"context"
"fmt"

"github.com/pkg/errors"
corev1 "k8s.io/api/core/v1"
Expand All @@ -11,18 +12,36 @@ import (
"k8s.io/client-go/util/retry"

"github.com/percona/percona-postgresql-operator/v2/internal/controller/postgrescluster"
"github.com/percona/percona-postgresql-operator/v2/internal/naming"
pNaming "github.com/percona/percona-postgresql-operator/v2/percona/naming"
v2 "github.com/percona/percona-postgresql-operator/v2/pkg/apis/pgv2.percona.com/v2"
"github.com/percona/percona-postgresql-operator/v2/pkg/apis/postgres-operator.crunchydata.com/v1beta1"
)

func (r *PGClusterReconciler) getHost(ctx context.Context, cr *v2.PerconaPGCluster) (string, error) {
svcName := cr.Name + "-pgbouncer"
postgresCluster := &v1beta1.PostgresCluster{
ObjectMeta: metav1.ObjectMeta{
Name: cr.Name,
Namespace: cr.Namespace,
},
}

svcFQDN := func(svcName, ns string) string {
return fmt.Sprintf("%s.%s.svc", svcName, ns)
}

// If proxy is not configured, use the primary service as host.
if cr.Spec.Proxy == nil || cr.Spec.Proxy.PGBouncer == nil {
return svcFQDN(naming.ClusterPrimaryService(postgresCluster).Name, postgresCluster.Namespace), nil
Copy link

Copilot AI Feb 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  1. Problem: When proxy/pgBouncer is not configured, getHost always returns the in-cluster primary Service FQDN, ignoring whether the primary Service is exposed as a LoadBalancer.
  2. Why it matters: .status.host is printed as the user-facing “Endpoint”; without pgBouncer, users exposing Postgres via .spec.expose.type=LoadBalancer will still see an internal-only DNS name.
  3. Fix: Mirror the existing LoadBalancer logic for the primary Service when proxy is nil: if .spec.expose is LoadBalancer, fetch the primary Service and prefer its ingress hostname/IP; otherwise return the Service FQDN.
Suggested change
return svcFQDN(naming.ClusterPrimaryService(postgresCluster).Name, postgresCluster.Namespace), nil
svcName := naming.ClusterPrimaryService(postgresCluster).Name
// If the primary is exposed via LoadBalancer, prefer its external hostname/IP.
if cr.Spec.Expose != nil && cr.Spec.Expose.Type == string(corev1.ServiceTypeLoadBalancer) {
svc := &corev1.Service{}
err := r.Client.Get(ctx, types.NamespacedName{Namespace: cr.Namespace, Name: svcName}, svc)
if err != nil {
return "", errors.Wrapf(err, "get %s service", svcName)
}
var host string
for _, i := range svc.Status.LoadBalancer.Ingress {
if len(i.Hostname) > 0 {
host = i.Hostname
} else if len(i.IP) > 0 {
host = i.IP
}
}
if host != "" {
return host, nil
}
}
return svcFQDN(svcName, postgresCluster.Namespace), nil

Copilot uses AI. Check for mistakes.
}

// Proxy is configured, but PGBouncer is not exposed, use the service name as host.
svcName := naming.ClusterPGBouncer(postgresCluster).Name
if cr.Spec.Proxy.PGBouncer.ServiceExpose == nil || cr.Spec.Proxy.PGBouncer.ServiceExpose.Type != string(corev1.ServiceTypeLoadBalancer) {
return svcName + "." + cr.Namespace + ".svc", nil
return svcFQDN(svcName, postgresCluster.Namespace), nil
}

// PGBouncer is exposed, find the IP/hostnames
svc := &corev1.Service{}
err := r.Client.Get(ctx, types.NamespacedName{Namespace: cr.Namespace, Name: svcName}, svc)
if err != nil {
Expand All @@ -36,7 +55,6 @@ func (r *PGClusterReconciler) getHost(ctx context.Context, cr *v2.PerconaPGClust
host = i.Hostname
}
}

return host, nil
}

Expand Down
30 changes: 18 additions & 12 deletions pkg/apis/pgv2.percona.com/v2/perconapgcluster_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -212,21 +212,23 @@ func (cr *PerconaPGCluster) Default() {
cr.Spec.InstanceSets[i].Metadata.Labels[LabelOperatorVersion] = cr.Spec.CRVersion
}

if cr.Spec.Proxy == nil {
cr.Spec.Proxy = new(PGProxySpec)
}
if cr.CompareVersion("2.9.0") < 0 || cr.Spec.Proxy.IsSet() {
if cr.Spec.Proxy == nil {
cr.Spec.Proxy = &PGProxySpec{}
}

if cr.Spec.Proxy.PGBouncer == nil {
cr.Spec.Proxy.PGBouncer = new(PGBouncerSpec)
}
if cr.Spec.Proxy.PGBouncer == nil {
cr.Spec.Proxy.PGBouncer = &PGBouncerSpec{}
}

if cr.Spec.Proxy.PGBouncer.Metadata == nil {
cr.Spec.Proxy.PGBouncer.Metadata = new(crunchyv1beta1.Metadata)
}
if cr.Spec.Proxy.PGBouncer.Metadata.Labels == nil {
cr.Spec.Proxy.PGBouncer.Metadata.Labels = make(map[string]string)
if cr.Spec.Proxy.PGBouncer.Metadata == nil {
cr.Spec.Proxy.PGBouncer.Metadata = &crunchyv1beta1.Metadata{}
}
if cr.Spec.Proxy.PGBouncer.Metadata.Labels == nil {
cr.Spec.Proxy.PGBouncer.Metadata.Labels = make(map[string]string)
}
cr.Spec.Proxy.PGBouncer.Metadata.Labels[LabelOperatorVersion] = cr.Spec.CRVersion
}
cr.Spec.Proxy.PGBouncer.Metadata.Labels[LabelOperatorVersion] = cr.Spec.CRVersion

t := true
f := false
Expand Down Expand Up @@ -1040,6 +1042,10 @@ type PGProxySpec struct {
PGBouncer *PGBouncerSpec `json:"pgBouncer"`
}

func (p *PGProxySpec) IsSet() bool {
return p != nil && p.PGBouncer != nil
}

func (p *PGProxySpec) ToCrunchy(version string) *crunchyv1beta1.PostgresProxySpec {
if p == nil {
Copy link

Copilot AI Feb 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  1. Problem: PGProxySpec.ToCrunchy will panic when .spec.proxy is present but .spec.proxy.pgBouncer is nil, because it later dereferences p.PGBouncer without a nil guard.
  2. Why it matters: With this PR, Default() can leave Proxy non-nil while PGBouncer is nil (e.g. proxy: {}), making this crash reachable during PerconaPGCluster.ToCrunchy.
  3. Fix: In ToCrunchy, treat p == nil || p.PGBouncer == nil as “proxy disabled” and return nil (or otherwise guard the dereference) before calling into p.PGBouncer.
Suggested change
if p == nil {
if p == nil || p.PGBouncer == nil {

Copilot uses AI. Check for mistakes.
return nil
Expand Down
Loading
Loading