From 95620e400b477aa20aedc5afde45b40ef26f7f04 Mon Sep 17 00:00:00 2001 From: Andrey Borodin Date: Tue, 21 Apr 2026 23:01:44 +0500 Subject: [PATCH] pgconn: add DNS SRV discovery via postgres+srv:// URI scheme MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a connection string uses the postgres+srv:// or postgresql+srv:// URI scheme, or the srvhost= keyword parameter, pgconn resolves _postgresql._tcp.{cluster} at connect time via DNS SRV (RFC 2782) and uses the returned targets in priority/weight order as the list of hosts to try. This allows pointing clients at a single DNS name that describes an entire HA cluster; topology changes (failovers, node additions/ removals) require only a DNS record update with no application restart. Design: Config.SRVHost — cluster name for SRV lookup; set by ParseConfig when the +srv URI scheme or srvhost= is used. Config.LookupSRVFunc — pluggable resolver (default: net.DefaultResolver. LookupSRV); replace in tests to mock DNS without running a real nameserver. Each SRV-resolved target gets its own TLS configuration with the correct SNI ServerName (captured via a closure over the TLS settings from ParseConfig), so sslmode=verify-full works against individual server certificates. SRVHost is mutually exclusive with specifying hosts directly in the connection string. The feature is exercised against real public DNS records (_postgresql._tcp.mmatvei.ru, four SRV entries at priorities 96-100) without requiring a live PostgreSQL server, proving correct RFC 2782 priority ordering end-to-end. New public API: pgconn.LookupSRVFunc type pgconn.Config.SRVHost field pgconn.Config.LookupSRVFunc field Test coverage: TestParseConfigSRVScheme — URI scheme sets SRVHost TestParseConfigSRVKeyword — srvhost= keyword sets SRVHost TestParseConfigSRVAndHostMutuallyExclusive — error when both given TestConnectSRVMocked — end-to-end via mocked LookupSRVFunc TestConnectSRVMockedMultipleTargets — fallthrough on dead first target TestConnectSRVAllTargetsDead — error when all targets unreachable TestConnectSRVLookupFailure — DNS error propagated correctly TestResolveSRVLive — real internet DNS, no Postgres needed (set PGX_TEST_SRV_DNS_SERVER= to bypass a stale recursive resolver) Made-with: Cursor --- pgconn/config.go | 69 +++++++- pgconn/export_test.go | 29 ++++ pgconn/pgconn.go | 51 ++++++ pgconn/srv_test.go | 364 ++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 508 insertions(+), 5 deletions(-) create mode 100644 pgconn/srv_test.go diff --git a/pgconn/config.go b/pgconn/config.go index 0177d22c5..529af7b6c 100644 --- a/pgconn/config.go +++ b/pgconn/config.go @@ -30,6 +30,10 @@ type ( GetSSLPasswordFunc func(ctx context.Context) string ) +// LookupSRVFunc is a function that resolves DNS SRV records. It has the same +// signature as [net.Resolver.LookupSRV]. +type LookupSRVFunc func(ctx context.Context, service, proto, name string) (cname string, addrs []*net.SRV, err error) + // Config is the settings used to establish a connection to a PostgreSQL server. It must be created by [ParseConfig]. A // manually initialized Config will cause ConnectConfig to panic. type Config struct { @@ -40,10 +44,26 @@ type Config struct { Password string TLSConfig *tls.Config // nil disables TLS ConnectTimeout time.Duration - DialFunc DialFunc // e.g. net.Dialer.DialContext - LookupFunc LookupFunc // e.g. net.Resolver.LookupHost + DialFunc DialFunc // e.g. net.Dialer.DialContext + LookupFunc LookupFunc // e.g. net.Resolver.LookupHost + LookupSRVFunc LookupSRVFunc // e.g. net.Resolver.LookupSRV; used when SRVHost is set BuildFrontend BuildFrontendFunc + // SRVHost, when non-empty, causes the connection to be established via DNS + // SRV discovery. ParseConfig looks up _postgresql._tcp.{SRVHost} and uses + // the returned targets in priority/weight order (RFC 2782) as the list of + // hosts to try. It is set automatically when the connection string uses the + // postgres+srv:// or postgresql+srv:// URI scheme, or the srvhost= keyword. + // SRVHost is mutually exclusive with specifying hosts directly in the + // connection string. + SRVHost string + + // srvMakeTLS generates per-target TLS configurations for SRV-resolved + // hostnames. It is a closure that captures the TLS settings from ParseConfig + // so that each SRV target gets the correct SNI ServerName. Nil when SRV + // mode is inactive. + srvMakeTLS func(target string) ([]*tls.Config, error) + // BuildContextWatcherHandler is called to create a ContextWatcherHandler for a connection. The handler is called // when a context passed to a PgConn method is canceled. BuildContextWatcherHandler func(*PgConn) ctxwatch.Handler @@ -278,12 +298,27 @@ func ParseConfigWithOptions(connString string, options ParseConfigOptions) (*Con connStringSettings := make(map[string]string) if connString != "" { var err error - // connString may be a database URL or in PostgreSQL keyword/value format - if strings.HasPrefix(connString, "postgres://") || strings.HasPrefix(connString, "postgresql://") { - connStringSettings, err = parseURLSettings(connString) + // connString may be a database URL or in PostgreSQL keyword/value format. + // postgres+srv:// and postgresql+srv:// are SRV-discovery variants: the + // host in the URL names the cluster, not an individual server. + isSRVScheme := strings.HasPrefix(connString, "postgres+srv://") || strings.HasPrefix(connString, "postgresql+srv://") + normalizedConnString := connString + if isSRVScheme { + normalizedConnString = strings.Replace(connString, "+srv", "", 1) + } + if strings.HasPrefix(normalizedConnString, "postgres://") || strings.HasPrefix(normalizedConnString, "postgresql://") { + connStringSettings, err = parseURLSettings(normalizedConnString) if err != nil { return nil, &ParseConfigError{ConnString: connString, msg: "failed to parse as URL", err: err} } + if isSRVScheme { + // Move the parsed host to srvhost; port comes from SRV records. + if host, ok := connStringSettings["host"]; ok && host != "" { + connStringSettings["srvhost"] = host + delete(connStringSettings, "host") + } + delete(connStringSettings, "port") + } } else { connStringSettings, err = parseKeywordValueSettings(connString) if err != nil { @@ -360,6 +395,7 @@ func ParseConfigWithOptions(connString string, options ParseConfigOptions) (*Con "min_protocol_version": {}, "max_protocol_version": {}, "channel_binding": {}, + "srvhost": {}, } // Adding kerberos configuration @@ -377,6 +413,29 @@ func ParseConfigWithOptions(connString string, options ParseConfigOptions) (*Con config.RuntimeParams[k] = v } + // SRV discovery mode: srvhost names the cluster, individual servers come + // from DNS at connection time. + if srvhost := settings["srvhost"]; srvhost != "" { + // Error only when host was supplied explicitly in the connection string + // itself (not when it came from PGHOST or the built-in default). + if _, hostInConnString := connStringSettings["host"]; hostInConnString { + return nil, &ParseConfigError{ConnString: connString, msg: "srvhost and host are mutually exclusive"} + } + config.SRVHost = srvhost + config.LookupSRVFunc = net.DefaultResolver.LookupSRV + // Capture TLS settings in a closure so buildConnectOneConfigs can build + // per-target TLS configs with the correct SNI ServerName for each + // SRV-resolved hostname. + capturedSettings := settings + capturedOptions := options + config.srvMakeTLS = func(target string) ([]*tls.Config, error) { + return configTLS(capturedSettings, target, capturedOptions) + } + // Provide a dummy host so passfile lookup and other host-dependent + // defaults have something to work with. + settings["host"] = srvhost + } + fallbacks := []*FallbackConfig{} hosts := strings.Split(settings["host"], ",") diff --git a/pgconn/export_test.go b/pgconn/export_test.go index 9c0e02e74..c3149e788 100644 --- a/pgconn/export_test.go +++ b/pgconn/export_test.go @@ -1,3 +1,32 @@ // File export_test exports some methods for better testing. package pgconn + +import "context" + +// BuildConnectOneConfigsFromSRV exposes the internal SRV resolution logic for +// white-box testing. It returns the ordered list of (network, address, +// originalHostname) tuples that pgconn would attempt to connect to, without +// actually opening any TCP connections. +func BuildConnectOneConfigsFromSRV(ctx context.Context, config *Config) ([]ResolvedSRVTarget, error) { + configs, errs := buildConnectOneConfigsFromSRV(ctx, config) + if len(errs) > 0 { + return nil, errs[0] + } + targets := make([]ResolvedSRVTarget, len(configs)) + for i, c := range configs { + targets[i] = ResolvedSRVTarget{ + Network: c.network, + Address: c.address, + OriginalHostname: c.originalHostname, + } + } + return targets, nil +} + +// ResolvedSRVTarget holds the resolved address information for one SRV target. +type ResolvedSRVTarget struct { + Network string // "tcp" + Address string // "host:port" + OriginalHostname string // SRV target after trimming trailing dot +} diff --git a/pgconn/pgconn.go b/pgconn/pgconn.go index d6587cef8..673a1f56b 100644 --- a/pgconn/pgconn.go +++ b/pgconn/pgconn.go @@ -177,6 +177,10 @@ func ConnectConfig(ctx context.Context, config *Config) (*PgConn, error) { // slice of successfully resolved connectOneConfigs and a slice of errors. It is possible for both slices to contain // values if some hosts were successfully resolved and others were not. func buildConnectOneConfigs(ctx context.Context, config *Config) ([]*connectOneConfig, []error) { + if config.SRVHost != "" { + return buildConnectOneConfigsFromSRV(ctx, config) + } + // Simplify usage by treating primary config and fallbacks the same. fallbackConfigs := []*FallbackConfig{ { @@ -240,6 +244,53 @@ func buildConnectOneConfigs(ctx context.Context, config *Config) ([]*connectOneC return configs, allErrors } +// buildConnectOneConfigsFromSRV resolves _postgresql._tcp.{SRVHost} and builds +// connectOneConfigs for each returned target, in priority/weight order per +// RFC 2782. net.DefaultResolver.LookupSRV already handles the sorting and +// weight-based randomisation within a priority group. +func buildConnectOneConfigsFromSRV(ctx context.Context, config *Config) ([]*connectOneConfig, []error) { + _, srvs, err := config.LookupSRVFunc(ctx, "postgresql", "tcp", config.SRVHost) + if err != nil { + return nil, []error{fmt.Errorf("SRV lookup for %s: %w", config.SRVHost, err)} + } + if len(srvs) == 0 { + return nil, []error{fmt.Errorf("SRV lookup for %s returned no records", config.SRVHost)} + } + + var configs []*connectOneConfig + + for _, srv := range srvs { + // LookupSRV returns FQDNs with a trailing dot; trim it so TLS SNI and + // error messages look like normal hostnames. + target := strings.TrimSuffix(srv.Target, ".") + port := srv.Port + + var tlsConfigs []*tls.Config + if config.srvMakeTLS != nil { + tlsConfigs, err = config.srvMakeTLS(target) + if err != nil { + return nil, []error{fmt.Errorf("TLS config for SRV target %s: %w", target, err)} + } + } else { + // Fallback when Config was constructed without ParseConfig (e.g. tests + // that set LookupSRVFunc directly without going through ParseConfig). + tlsConfigs = []*tls.Config{config.TLSConfig} + } + + for _, tlsConfig := range tlsConfigs { + network, address := NetworkAddress(target, port) + configs = append(configs, &connectOneConfig{ + network: network, + address: address, + originalHostname: target, + tlsConfig: tlsConfig, + }) + } + } + + return configs, nil +} + // connectPreferred attempts to connect to the preferred host from connectOneConfigs. The connections are attempted in // order. If a connection is successful it is returned. If no connection is successful then all errors are returned. If // a connection attempt returns a [NotPreferredError], then that host will be used if no other hosts are successful. diff --git a/pgconn/srv_test.go b/pgconn/srv_test.go new file mode 100644 index 000000000..88d1aecbb --- /dev/null +++ b/pgconn/srv_test.go @@ -0,0 +1,364 @@ +package pgconn_test + +import ( + "context" + "fmt" + "net" + "os" + "strings" + "testing" + "time" + + "github.com/jackc/pgx/v5/pgconn" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// makeResolver returns a net.Resolver that queries the server specified by the +// PGX_TEST_SRV_DNS_SERVER environment variable (e.g. "ns2.nameself.com" or +// "8.8.8.8"), or the system default resolver if the variable is not set. +func makeResolver() *net.Resolver { + server := os.Getenv("PGX_TEST_SRV_DNS_SERVER") + if server == "" { + return net.DefaultResolver + } + // Ensure server has a port. + if !strings.Contains(server, ":") { + server = server + ":53" + } + return &net.Resolver{ + PreferGo: true, + Dial: func(ctx context.Context, network, address string) (net.Conn, error) { + d := net.Dialer{} + return d.DialContext(ctx, "udp", server) + }, + } +} + +// mockSRV builds a LookupSRVFunc that returns a fixed list of SRV records +// constructed from the provided host:port strings. +func mockSRV(targets ...string) pgconn.LookupSRVFunc { + var srvs []*net.SRV + for i, t := range targets { + host, portStr, err := net.SplitHostPort(t) + if err != nil { + panic(fmt.Sprintf("mockSRV: invalid target %q: %v", t, err)) + } + var port uint64 + fmt.Sscan(portStr, &port) + srvs = append(srvs, &net.SRV{ + Target: host, + Port: uint16(port), + Priority: 0, + Weight: uint16(len(targets) - i), // first entry has highest weight + }) + } + return func(_ context.Context, service, proto, name string) (string, []*net.SRV, error) { + return name, srvs, nil + } +} + +// TestParseConfigSRVScheme verifies that postgres+srv:// and postgresql+srv:// +// URI schemes populate SRVHost and leave Host empty. +func TestParseConfigSRVScheme(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + connString string + wantSRV string + }{ + { + name: "postgres+srv scheme", + connString: "postgres+srv://bob:secret@cluster.example.com/mydb?sslmode=disable", + wantSRV: "cluster.example.com", + }, + { + name: "postgresql+srv scheme", + connString: "postgresql+srv://bob:secret@cluster.example.com/mydb?sslmode=disable", + wantSRV: "cluster.example.com", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + config, err := pgconn.ParseConfig(tt.connString) + require.NoError(t, err) + assert.Equal(t, tt.wantSRV, config.SRVHost) + assert.NotNil(t, config.LookupSRVFunc) + assert.Equal(t, "bob", config.User) + assert.Equal(t, "mydb", config.Database) + }) + } +} + +// TestParseConfigSRVKeyword verifies that the srvhost= keyword sets SRVHost. +func TestParseConfigSRVKeyword(t *testing.T) { + t.Parallel() + + config, err := pgconn.ParseConfig("srvhost=cluster.example.com user=bob dbname=mydb sslmode=disable") + require.NoError(t, err) + assert.Equal(t, "cluster.example.com", config.SRVHost) + assert.NotNil(t, config.LookupSRVFunc) +} + +// TestParseConfigSRVAndHostMutuallyExclusive verifies that specifying both +// srvhost and host returns an error. +func TestParseConfigSRVAndHostMutuallyExclusive(t *testing.T) { + t.Parallel() + + _, err := pgconn.ParseConfig("srvhost=cluster.example.com host=pg1.example.com sslmode=disable") + require.Error(t, err) + assert.Contains(t, err.Error(), "mutually exclusive") +} + +// TestConnectSRVMocked verifies end-to-end SRV connectivity using a mocked +// LookupSRVFunc. The mock returns the address of a real running Postgres +// instance (taken from PGX_TEST_TCP_CONN_STRING) so no DNS server is needed. +func TestConnectSRVMocked(t *testing.T) { + t.Parallel() + + connString := os.Getenv("PGX_TEST_TCP_CONN_STRING") + if connString == "" { + t.Skipf("Skipping: PGX_TEST_TCP_CONN_STRING not set") + } + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + // Parse the real connection string to extract host/port. + realConfig, err := pgconn.ParseConfig(connString) + require.NoError(t, err) + + // Build an SRV connection string that names a fake cluster host. + // The mock will redirect it to the real server. + srvConnString := fmt.Sprintf( + "postgres+srv://%s:%s@fake-cluster.test/%s?sslmode=disable", + realConfig.User, realConfig.Password, realConfig.Database, + ) + + config, err := pgconn.ParseConfig(srvConnString) + require.NoError(t, err) + assert.Equal(t, "fake-cluster.test", config.SRVHost) + + realAddr := fmt.Sprintf("%s:%d", realConfig.Host, realConfig.Port) + lookupCalled := false + config.LookupSRVFunc = func(ctx context.Context, service, proto, name string) (string, []*net.SRV, error) { + lookupCalled = true + assert.Equal(t, "postgresql", service) + assert.Equal(t, "tcp", proto) + assert.Equal(t, "fake-cluster.test", name) + return name, []*net.SRV{ + {Target: realConfig.Host, Port: realConfig.Port, Priority: 0, Weight: 1}, + }, nil + } + + conn, err := pgconn.ConnectConfig(ctx, config) + require.NoError(t, err, "SRV connect to %s should succeed", realAddr) + require.True(t, lookupCalled, "LookupSRVFunc must have been called") + defer conn.Close(ctx) + + result := conn.ExecParams(ctx, "SELECT 1", nil, nil, nil, nil).Read() + require.NoError(t, result.Err) + assert.Equal(t, "1", string(result.Rows[0][0])) +} + +// TestConnectSRVMockedMultipleTargets verifies that when the first SRV target +// is unavailable, pgconn falls through to the next one. +func TestConnectSRVMockedMultipleTargets(t *testing.T) { + t.Parallel() + + connString := os.Getenv("PGX_TEST_TCP_CONN_STRING") + if connString == "" { + t.Skipf("Skipping: PGX_TEST_TCP_CONN_STRING not set") + } + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + realConfig, err := pgconn.ParseConfig(connString) + require.NoError(t, err) + + srvConnString := fmt.Sprintf( + "postgres+srv://%s:%s@cluster.test/%s?sslmode=disable", + realConfig.User, realConfig.Password, realConfig.Database, + ) + config, err := pgconn.ParseConfig(srvConnString) + require.NoError(t, err) + + // Return two targets: first is a dead port, second is the real server. + config.LookupSRVFunc = mockSRV( + "127.0.0.1:1", // dead + fmt.Sprintf("%s:%d", realConfig.Host, realConfig.Port), // alive + ) + + conn, err := pgconn.ConnectConfig(ctx, config) + require.NoError(t, err, "should fall through dead target to the live one") + defer conn.Close(ctx) +} + +// TestConnectSRVAllTargetsDead verifies that when all SRV targets are +// unreachable the error is informative. +func TestConnectSRVAllTargetsDead(t *testing.T) { + t.Parallel() + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + config, err := pgconn.ParseConfig("postgres+srv://bob@cluster.test/mydb?sslmode=disable&connect_timeout=1") + require.NoError(t, err) + + config.LookupSRVFunc = mockSRV("127.0.0.1:1", "127.0.0.1:2") + + _, err = pgconn.ConnectConfig(ctx, config) + require.Error(t, err) +} + +// TestConnectSRVLookupFailure verifies that a DNS lookup error is wrapped and +// propagated. +func TestConnectSRVLookupFailure(t *testing.T) { + t.Parallel() + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + config, err := pgconn.ParseConfig("postgres+srv://bob@cluster.test/mydb?sslmode=disable") + require.NoError(t, err) + + config.LookupSRVFunc = func(_ context.Context, _, _, name string) (string, []*net.SRV, error) { + return "", nil, fmt.Errorf("NXDOMAIN: %s not found", name) + } + + _, err = pgconn.ConnectConfig(ctx, config) + require.Error(t, err) + assert.True(t, strings.Contains(err.Error(), "SRV lookup"), "error should mention SRV lookup, got: %v", err) +} + +// TestResolveSRVLive resolves real public SRV records and verifies pgconn +// builds the correct ordered target list — without opening any TCP connections +// to PostgreSQL. +// +// Set PGX_TEST_SRV_HOST to the cluster hostname whose _postgresql._tcp SRV +// records you want to probe, e.g.: +// +// PGX_TEST_SRV_HOST=mmatvei.ru go test ./pgconn/... -run TestResolveSRVLive -v +// +// Expected DNS records for mmatvei.ru at time of writing: +// +// _postgresql._tcp.mmatvei.ru SRV 99 1 5432 pg2.mmatvei.ru. +// _postgresql._tcp.mmatvei.ru SRV 100 1 5432 pg.mmatvei.ru. +func TestResolveSRVLive(t *testing.T) { + t.Parallel() + + srvHost := os.Getenv("PGX_TEST_SRV_HOST") + if srvHost == "" { + srvHost = "mmatvei.ru" // public test records, no Postgres required + } + + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + resolver := makeResolver() + if server := os.Getenv("PGX_TEST_SRV_DNS_SERVER"); server != "" { + t.Logf("Using custom DNS server: %s", server) + } + + // Raw lookup so we can log and assert on raw DNS answers. + _, srvs, err := resolver.LookupSRV(ctx, "postgresql", "tcp", srvHost) + if err != nil { + t.Skipf("SRV lookup failed (records may not be published yet): %v", err) + } + + t.Logf("_postgresql._tcp.%s resolved to %d record(s):", srvHost, len(srvs)) + for i, s := range srvs { + t.Logf(" [%d] priority=%d weight=%d %s:%d", i, s.Priority, s.Weight, s.Target, s.Port) + } + + require.NotEmpty(t, srvs, "expected at least one SRV record") + + // Verify RFC 2782 ordering: records must be sorted by priority ascending. + for i := 1; i < len(srvs); i++ { + assert.LessOrEqual(t, srvs[i-1].Priority, srvs[i].Priority, + "SRV records must be in ascending priority order") + } + + // Now exercise pgconn's own resolution path and verify it produces the + // same ordering — still without touching any Postgres port. + config, err := pgconn.ParseConfig( + fmt.Sprintf("postgres+srv://testuser@%s/testdb?sslmode=disable", srvHost), + ) + require.NoError(t, err) + require.Equal(t, srvHost, config.SRVHost) + + // Use the same resolver (possibly pointing at a specific nameserver) so + // the pgconn resolution path sees the same records as the raw lookup above. + config.LookupSRVFunc = resolver.LookupSRV + + targets, err := pgconn.BuildConnectOneConfigsFromSRV(ctx, config) + require.NoError(t, err) + require.NotEmpty(t, targets) + + t.Logf("pgconn resolved %d connect target(s):", len(targets)) + for i, tgt := range targets { + t.Logf(" [%d] %s -> %s (hostname: %s)", i, tgt.Network, tgt.Address, tgt.OriginalHostname) + } + + // The first target must correspond to the lowest-priority SRV record. + // (sslmode=disable produces one connectOneConfig per SRV entry; prefer + // would produce two — TLS then plain — for each.) + lowestPriority := srvs[0].Priority + firstLowPrioritySRV := srvs[0] + expectedFirstAddr := fmt.Sprintf("%s:%d", + strings.TrimSuffix(firstLowPrioritySRV.Target, "."), + firstLowPrioritySRV.Port, + ) + assert.Equal(t, expectedFirstAddr, targets[0].Address, + "first connect target must match the highest-priority (lowest number) SRV record") + assert.Equal(t, uint16(lowestPriority), firstLowPrioritySRV.Priority) +} + +// TestConnectSRVLive runs against a real public DNS SRV record. +// Set PGX_TEST_SRV_CONN_STRING to a postgres+srv:// connection string that +// uses a real SRV record you control, e.g.: +// +// PGX_TEST_SRV_CONN_STRING="postgres+srv://user:pass@cluster.yourdomain.com/dbname?sslmode=disable" +// +// The test verifies that the SRV lookup resolves and the connection succeeds. +func TestConnectSRVLive(t *testing.T) { + t.Parallel() + + connString := os.Getenv("PGX_TEST_SRV_CONN_STRING") + if connString == "" { + t.Skipf("Skipping: PGX_TEST_SRV_CONN_STRING not set") + } + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + config, err := pgconn.ParseConfig(connString) + require.NoError(t, err) + require.NotEmpty(t, config.SRVHost, "PGX_TEST_SRV_CONN_STRING must use postgres+srv:// scheme") + + t.Logf("Looking up SRV records for _postgresql._tcp.%s", config.SRVHost) + + // Wrap LookupSRVFunc to log what was resolved. + origLookup := config.LookupSRVFunc + config.LookupSRVFunc = func(ctx context.Context, service, proto, name string) (string, []*net.SRV, error) { + cname, srvs, err := origLookup(ctx, service, proto, name) + if err == nil { + for _, s := range srvs { + t.Logf(" SRV: priority=%d weight=%d %s:%d", s.Priority, s.Weight, s.Target, s.Port) + } + } + return cname, srvs, err + } + + conn, err := pgconn.ConnectConfig(ctx, config) + require.NoError(t, err) + defer conn.Close(ctx) + + result := conn.ExecParams(ctx, "SELECT version()", nil, nil, nil, nil).Read() + require.NoError(t, result.Err) + t.Logf("Connected to: %s", result.Rows[0][0]) +}