diff --git a/internal/postgresql/connection.go b/internal/postgresql/connection.go new file mode 100644 index 0000000..69c7bca --- /dev/null +++ b/internal/postgresql/connection.go @@ -0,0 +1,105 @@ +/* +Copyright 2026. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package postgresql + +import ( + "context" + "crypto/x509" + "errors" + "net" + "net/url" + "strconv" + + api "git.ddupan.top/panxiao81/postgresql-tenant-operator/api/v1alpha1" + "git.ddupan.top/panxiao81/postgresql-tenant-operator/internal/instance" + "git.ddupan.top/panxiao81/postgresql-tenant-operator/internal/postgresql/registry" + "github.com/jackc/pgx/v5/pgconn" + "github.com/jackc/pgx/v5/pgxpool" +) + +// Connector is configured once with the deployment trust bundle. +type Connector struct{ CABundlePath string } + +func (c Connector) Connect(ctx context.Context, endpoint api.PostgreSQLEndpoint, credentials instance.Credentials) (instance.Database, error) { + if endpoint.SSLMode != api.PostgreSQLSSLModeDisable && c.CABundlePath == "" { + return nil, instance.Failure{Reason: api.ReasonInvalidSpec, Operation: "PostgreSQL TLS requires a CA bundle"} + } + address := &url.URL{ + Scheme: "postgresql", + Host: net.JoinHostPort(endpoint.Host, strconv.Itoa(int(endpoint.Port))), + Path: endpoint.Database, + } + query := address.Query() + query.Set("sslmode", string(endpoint.SSLMode)) + if endpoint.SSLMode != api.PostgreSQLSSLModeDisable { + query.Set("sslrootcert", c.CABundlePath) + } + address.RawQuery = query.Encode() + config, err := pgxpool.ParseConfig(address.String()) + if err != nil { + return nil, classify("configure PostgreSQL connection", err) + } + // Avoid embedding credentials in the driver's original connection string. + config.ConnConfig.User = credentials.Username + config.ConnConfig.Password = credentials.Password + config.MaxConns = 2 + pool, err := pgxpool.NewWithConfig(ctx, config) + if err != nil { + return nil, classify("create PostgreSQL pool", err) + } + // Pool construction is lazy. Version performs the first authenticated query. + return &Database{pool: pool}, nil +} + +type Database struct{ pool *pgxpool.Pool } + +func (d *Database) Version(ctx context.Context) (string, error) { + var version string + if err := d.pool.QueryRow(ctx, "SHOW server_version").Scan(&version); err != nil { + return "", classify("read PostgreSQL server version", err) + } + return version, nil +} + +func (d *Database) EnsureRegistry(ctx context.Context) error { + if err := registry.NewStore(d.pool).Bootstrap(ctx); err != nil { + return classify("initialize PostgreSQL registry", err) + } + return nil +} + +func (d *Database) Close() { d.pool.Close() } + +func classify(operation string, err error) error { + reason := api.ReasonDependencyUnavailable + var pgError *pgconn.PgError + var unknownCA x509.UnknownAuthorityError + var hostname x509.HostnameError + var invalidCertificate x509.CertificateInvalidError + switch { + case errors.As(err, &pgError): + switch pgError.Code { + case "28P01", "28000": + reason = api.ReasonAuthenticationFailed + case "42501": + reason = api.ReasonInsufficientPrivileges + } + case errors.As(err, &unknownCA), errors.As(err, &hostname), errors.As(err, &invalidCertificate): + reason = api.ReasonAuthenticationFailed + } + return instance.Failure{Reason: reason, Operation: operation} +} diff --git a/internal/postgresql/connection_test.go b/internal/postgresql/connection_test.go new file mode 100644 index 0000000..df510ef --- /dev/null +++ b/internal/postgresql/connection_test.go @@ -0,0 +1,53 @@ +/* +Copyright 2026. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package postgresql + +import ( + "context" + "errors" + "fmt" + "strings" + "testing" + + api "git.ddupan.top/panxiao81/postgresql-tenant-operator/api/v1alpha1" + "git.ddupan.top/panxiao81/postgresql-tenant-operator/internal/instance" + "github.com/jackc/pgx/v5/pgconn" +) + +const testCanary = "canary-secret" + +func TestDatabaseFailuresDoNotLeakDriverMessages(t *testing.T) { + tests := []struct { + err error + reason string + }{ + {context.DeadlineExceeded, api.ReasonDependencyUnavailable}, + {&pgconn.PgError{Code: "28P01", Message: testCanary}, api.ReasonAuthenticationFailed}, + {&pgconn.PgError{Code: "42501", Message: testCanary}, api.ReasonInsufficientPrivileges}, + {&pgconn.PgError{Code: "53300", Message: testCanary}, api.ReasonDependencyUnavailable}, + } + for _, tt := range tests { + err := classify("database operation", fmt.Errorf("driver canary-secret: %w", tt.err)) + var failure instance.Failure + if !errors.As(err, &failure) || failure.Reason != tt.reason { + t.Fatal("wrong error category") + } + if strings.Contains(fmt.Sprintf("%+v %#v", err, err), "canary") { + t.Fatal("driver message escaped") + } + } +}