217 lines
7.9 KiB
Go
217 lines
7.9 KiB
Go
/*
|
|
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 instance validates PostgreSQLInstance dependencies and initializes
|
|
// the controller registry without exposing administrative credentials.
|
|
package instance
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"net"
|
|
"net/url"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
kubernetesauth "github.com/openbao/openbao/api/auth/kubernetes/v2"
|
|
openbao "github.com/openbao/openbao/api/v2"
|
|
"k8s.io/apimachinery/pkg/util/validation"
|
|
|
|
databasev1alpha1 "git.ddupan.top/panxiao81/postgresql-tenant-operator/api/v1alpha1"
|
|
"git.ddupan.top/panxiao81/postgresql-tenant-operator/internal/postgresql/registry"
|
|
)
|
|
|
|
// Config contains the deployment-level settings needed by Instance readiness.
|
|
type Config struct {
|
|
OpenBaoAddress string
|
|
OpenBaoConsumerAddress string
|
|
OpenBaoAuthMount string
|
|
OpenBaoAuthRole string
|
|
ServiceAccountTokenPath string
|
|
OpenBaoKVMount string
|
|
OpenBaoTenantBasePath string
|
|
ExternalSecretStoreName string
|
|
PostgreSQLCABundlePath string
|
|
Timeout time.Duration
|
|
}
|
|
|
|
// Initializer implements the Instance validation and registry phases.
|
|
type Initializer struct{ config Config }
|
|
|
|
type failure struct {
|
|
reason string
|
|
message string
|
|
}
|
|
|
|
func (e failure) Error() string { return e.message }
|
|
func (e failure) ConditionReason() string { return e.reason }
|
|
|
|
func dependencyFailure(message string) error {
|
|
return failure{reason: databasev1alpha1.ReasonDependencyUnavailable, message: message}
|
|
}
|
|
|
|
func authenticationFailure(message string) error {
|
|
return failure{reason: databasev1alpha1.ReasonAuthenticationFailed, message: message}
|
|
}
|
|
|
|
func privilegeFailure(message string) error {
|
|
return failure{reason: databasev1alpha1.ReasonInsufficientPrivileges, message: message}
|
|
}
|
|
|
|
// New validates config and constructs an Initializer.
|
|
func New(config Config) (*Initializer, error) {
|
|
if config.OpenBaoAddress == "" || config.OpenBaoAuthRole == "" || config.OpenBaoAuthMount == "" ||
|
|
config.ServiceAccountTokenPath == "" || config.OpenBaoKVMount == "" || config.OpenBaoTenantBasePath == "" ||
|
|
config.ExternalSecretStoreName == "" || config.Timeout <= 0 {
|
|
return nil, errors.New("initialize instance dependencies: required configuration is missing")
|
|
}
|
|
if err := validateAddress(config.OpenBaoAddress); err != nil {
|
|
return nil, err
|
|
}
|
|
if config.OpenBaoConsumerAddress == "" {
|
|
config.OpenBaoConsumerAddress = config.OpenBaoAddress
|
|
}
|
|
if err := validateAddress(config.OpenBaoConsumerAddress); err != nil {
|
|
return nil, err
|
|
}
|
|
if !filepath.IsAbs(config.ServiceAccountTokenPath) || validateRelativePath(config.OpenBaoAuthMount, false) != nil ||
|
|
validateRelativePath(config.OpenBaoKVMount, false) != nil || validateRelativePath(config.OpenBaoTenantBasePath, true) != nil ||
|
|
len(validation.IsDNS1123Subdomain(config.ExternalSecretStoreName)) != 0 {
|
|
return nil, errors.New("initialize instance dependencies: invalid path or resource name configuration")
|
|
}
|
|
return &Initializer{config: config}, nil
|
|
}
|
|
|
|
// Validate authenticates to OpenBao, reads the administrative credential, and
|
|
// verifies that PostgreSQL accepts it. It returns only public server metadata.
|
|
func (i *Initializer) Validate(ctx context.Context, instance *databasev1alpha1.PostgreSQLInstance) (string, error) {
|
|
pool, err := i.connect(ctx, instance)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
defer pool.Close()
|
|
|
|
var version string
|
|
if err := pool.QueryRow(ctx, "SHOW server_version").Scan(&version); err != nil {
|
|
return "", errors.New("validate PostgreSQL server metadata")
|
|
}
|
|
return version, nil
|
|
}
|
|
|
|
// InitializeRegistry repeats dependency validation and applies registry migrations.
|
|
func (i *Initializer) InitializeRegistry(ctx context.Context, instance *databasev1alpha1.PostgreSQLInstance) (string, error) {
|
|
pool, err := i.connect(ctx, instance)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
defer pool.Close()
|
|
|
|
if err := registry.NewStore(pool).Bootstrap(ctx); err != nil {
|
|
return "", privilegeFailure("initialize PostgreSQL registry")
|
|
}
|
|
var version string
|
|
if err := pool.QueryRow(ctx, "SHOW server_version").Scan(&version); err != nil {
|
|
return "", errors.New("validate PostgreSQL server metadata")
|
|
}
|
|
return version, nil
|
|
}
|
|
|
|
func (i *Initializer) connect(ctx context.Context, instance *databasev1alpha1.PostgreSQLInstance) (*pgxpool.Pool, error) {
|
|
if instance.Spec.Endpoint.SSLMode != databasev1alpha1.PostgreSQLSSLModeDisable && i.config.PostgreSQLCABundlePath == "" {
|
|
return nil, errors.New("configure PostgreSQL TLS: CA bundle path is required")
|
|
}
|
|
ctx, cancel := context.WithTimeout(ctx, i.config.Timeout)
|
|
defer cancel()
|
|
|
|
clientConfig := openbao.DefaultConfig()
|
|
clientConfig.Address = i.config.OpenBaoAddress
|
|
clientConfig.Timeout = i.config.Timeout
|
|
clientConfig.DisableEnvironment = true
|
|
client, err := openbao.NewClient(clientConfig)
|
|
if err != nil {
|
|
return nil, dependencyFailure("create OpenBao client")
|
|
}
|
|
auth, err := kubernetesauth.NewKubernetesAuth(
|
|
i.config.OpenBaoAuthRole,
|
|
kubernetesauth.WithMountPath(i.config.OpenBaoAuthMount),
|
|
kubernetesauth.WithServiceAccountTokenPath(i.config.ServiceAccountTokenPath),
|
|
)
|
|
if err != nil {
|
|
return nil, errors.New("configure OpenBao Kubernetes authentication")
|
|
}
|
|
if secret, err := client.Auth().Login(ctx, auth); err != nil || secret == nil || secret.Auth == nil {
|
|
return nil, authenticationFailure("authenticate to OpenBao")
|
|
}
|
|
|
|
secret, err := client.KVv2(i.config.OpenBaoKVMount).Get(ctx, instance.Spec.AdminCredentialRef.Path)
|
|
if err != nil {
|
|
return nil, privilegeFailure("read PostgreSQL administrative credential")
|
|
}
|
|
username, usernameOK := secret.Data[instance.Spec.AdminCredentialRef.UsernameKey].(string)
|
|
password, passwordOK := secret.Data[instance.Spec.AdminCredentialRef.PasswordKey].(string)
|
|
if !usernameOK || !passwordOK || username == "" || password == "" {
|
|
return nil, errors.New("read PostgreSQL administrative credential fields")
|
|
}
|
|
|
|
connectionURL := &url.URL{
|
|
Scheme: "postgresql",
|
|
User: url.UserPassword(username, password),
|
|
Host: net.JoinHostPort(instance.Spec.Endpoint.Host, strconv.Itoa(int(instance.Spec.Endpoint.Port))),
|
|
Path: instance.Spec.Endpoint.Database,
|
|
}
|
|
query := connectionURL.Query()
|
|
query.Set("sslmode", string(instance.Spec.Endpoint.SSLMode))
|
|
if instance.Spec.Endpoint.SSLMode != databasev1alpha1.PostgreSQLSSLModeDisable {
|
|
query.Set("sslrootcert", i.config.PostgreSQLCABundlePath)
|
|
}
|
|
connectionURL.RawQuery = query.Encode()
|
|
poolConfig, err := pgxpool.ParseConfig(connectionURL.String())
|
|
if err != nil {
|
|
return nil, errors.New("configure PostgreSQL connection")
|
|
}
|
|
pool, err := pgxpool.NewWithConfig(ctx, poolConfig)
|
|
if err != nil {
|
|
return nil, errors.New("connect to PostgreSQL")
|
|
}
|
|
if err := pool.Ping(ctx); err != nil {
|
|
pool.Close()
|
|
return nil, authenticationFailure("authenticate to PostgreSQL")
|
|
}
|
|
return pool, nil
|
|
}
|
|
|
|
func validateAddress(value string) error {
|
|
parsed, err := url.Parse(value)
|
|
if err != nil || !parsed.IsAbs() || (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.Host == "" ||
|
|
parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" {
|
|
return errors.New("initialize instance dependencies: invalid OpenBao address")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func validateRelativePath(value string, rejectAPILayer bool) error {
|
|
for index, segment := range strings.Split(value, "/") {
|
|
if segment == "" || segment == "." || segment == ".." ||
|
|
(rejectAPILayer && index == 0 && (segment == "data" || segment == "metadata")) {
|
|
return errors.New("invalid mount-relative path")
|
|
}
|
|
}
|
|
return nil
|
|
}
|