diff --git a/pkg/remote_replica/local_ca.go b/pkg/remote_replica/local_ca.go new file mode 100644 index 000000000..568623497 --- /dev/null +++ b/pkg/remote_replica/local_ca.go @@ -0,0 +1,179 @@ +/* +Copyright AppsCode Inc. and Contributors + +Licensed under the AppsCode Community License 1.0.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + https://github.com/appscode/licenses/raw/1.0.0/AppsCode-Community-1.0.0.md + +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 remote_replica + +import ( + "crypto" + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "fmt" + "math/big" + "os" + "time" + + "kubedb.dev/cli/pkg/common" + + core "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/yaml" +) + +// clientCertValidity is how long a locally issued client certificate stays valid. +// It is clamped to the CA's own NotAfter, since a leaf outliving its CA is useless. +const clientCertValidity = 365 * 24 * time.Hour + +// parseCAPair decodes a PEM CA certificate and its private key and verifies they +// belong together. Signing a client certificate requires the CA's PRIVATE key; +// the public ca.crt alone cannot issue anything. +func parseCAPair(caCertPEM, caKeyPEM []byte) (*x509.Certificate, crypto.Signer, error) { + block, _ := pem.Decode(caCertPEM) + if block == nil || block.Type != "CERTIFICATE" { + return nil, nil, fmt.Errorf("--ca-cert does not contain a PEM CERTIFICATE block") + } + caCert, err := x509.ParseCertificate(block.Bytes) + if err != nil { + return nil, nil, fmt.Errorf("failed to parse --ca-cert: %v", err) + } + if !caCert.IsCA { + return nil, nil, fmt.Errorf("--ca-cert is not a CA certificate (BasicConstraints CA=false); it cannot sign client certificates") + } + + keyBlock, _ := pem.Decode(caKeyPEM) + if keyBlock == nil { + return nil, nil, fmt.Errorf("--ca-key does not contain a PEM block") + } + var key any + switch keyBlock.Type { + case "RSA PRIVATE KEY": + key, err = x509.ParsePKCS1PrivateKey(keyBlock.Bytes) + case "EC PRIVATE KEY": + key, err = x509.ParseECPrivateKey(keyBlock.Bytes) + case "PRIVATE KEY": + key, err = x509.ParsePKCS8PrivateKey(keyBlock.Bytes) + default: + return nil, nil, fmt.Errorf("--ca-key: unsupported PEM block type %q", keyBlock.Type) + } + if err != nil { + return nil, nil, fmt.Errorf("failed to parse --ca-key: %v", err) + } + signer, ok := key.(crypto.Signer) + if !ok { + return nil, nil, fmt.Errorf("--ca-key is not a usable signing key") + } + + type pubEqualer interface{ Equal(crypto.PublicKey) bool } + pub, ok := caCert.PublicKey.(pubEqualer) + if !ok || !pub.Equal(signer.Public()) { + return nil, nil, fmt.Errorf("--ca-key does not match --ca-cert (public keys differ)") + } + return caCert, signer, nil +} + +// issueClientCertFromCA generates a fresh RSA key pair and a client-auth +// certificate for userName signed by the given CA. The certificate's CommonName +// is the username — that is what PostgreSQL cert authentication maps to the +// database role — and dnsSANs land in the SAN extension. +func issueClientCertFromCA(userName string, dnsSANs []string, caCertPEM, caKeyPEM []byte) (certPEM, keyPEM []byte, err error) { + caCert, caKey, err := parseCAPair(caCertPEM, caKeyPEM) + if err != nil { + return nil, nil, err + } + + key, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + return nil, nil, fmt.Errorf("failed to generate client key: %v", err) + } + serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128)) + if err != nil { + return nil, nil, fmt.Errorf("failed to generate serial number: %v", err) + } + + now := time.Now() + notAfter := now.Add(clientCertValidity) + if notAfter.After(caCert.NotAfter) { + notAfter = caCert.NotAfter + } + tmpl := &x509.Certificate{ + SerialNumber: serial, + Subject: pkix.Name{CommonName: userName}, + DNSNames: dnsSANs, + NotBefore: now.Add(-5 * time.Minute), + NotAfter: notAfter, + KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth}, + BasicConstraintsValid: true, + } + der, err := x509.CreateCertificate(rand.Reader, tmpl, caCert, key.Public(), caKey) + if err != nil { + return nil, nil, fmt.Errorf("failed to sign client certificate: %v", err) + } + + certPEM = pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}) + keyDER, err := x509.MarshalPKCS8PrivateKey(key) + if err != nil { + return nil, nil, fmt.Errorf("failed to marshal client key: %v", err) + } + keyPEM = pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: keyDER}) + return certPEM, keyPEM, nil +} + +// generateTlsSecretFromLocalCA issues the client certificate locally from the CA +// files given on the command line — no cert-manager involved — and packages it as +// the kubernetes.io/tls Secret the remote replica will mount (ca.crt, tls.crt, +// tls.key; the operator remaps tls.* to client.* at mount time). +func generateTlsSecretFromLocalCA(userName, ns, caCertPath, caKeyPath string, dnsSANs []string, opts *common.PostgresOpts) ([]byte, string, error) { + caCertPEM, err := os.ReadFile(caCertPath) + if err != nil { + return nil, "", fmt.Errorf("failed to read --ca-cert: %v", err) + } + caKeyPEM, err := os.ReadFile(caKeyPath) + if err != nil { + return nil, "", fmt.Errorf("failed to read --ca-key: %v", err) + } + certPEM, keyPEM, err := issueClientCertFromCA(userName, dnsSANs, caCertPEM, caKeyPEM) + if err != nil { + return nil, "", err + } + + tlsSecret := core.Secret{ + TypeMeta: metav1.TypeMeta{ + Kind: KindSecret, + APIVersion: ApiversionV1, + }, + ObjectMeta: metav1.ObjectMeta{ + Name: fmt.Sprintf("%s-remote-replica-client-cert", opts.DB.Name), + Namespace: ns, + }, + Type: core.SecretTypeTLS, + Data: map[string][]byte{ + "ca.crt": caCertPEM, + "tls.crt": certPEM, + "tls.key": keyPEM, + }, + } + tlsSecretYaml, err := yaml.Marshal(tlsSecret) + if err != nil { + return nil, "", fmt.Errorf("failed to marshal tls secret yaml %v", err) + } + buffer := make([]byte, 0, len(tlsSecretYaml)+4) + buffer = append(buffer, tlsSecretYaml...) + buffer = append(buffer, []byte("---\n")...) + return buffer, tlsSecret.Name, nil +} diff --git a/pkg/remote_replica/local_ca_test.go b/pkg/remote_replica/local_ca_test.go new file mode 100644 index 000000000..2097c8c9f --- /dev/null +++ b/pkg/remote_replica/local_ca_test.go @@ -0,0 +1,197 @@ +/* +Copyright AppsCode Inc. and Contributors + +Licensed under the AppsCode Community License 1.0.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + https://github.com/appscode/licenses/raw/1.0.0/AppsCode-Community-1.0.0.md + +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 remote_replica + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "math/big" + "reflect" + "testing" + "time" +) + +// makeCA returns a self-signed CA certificate and private key as PEM. keyKind +// selects the key algorithm and PEM encoding, to cover the parse variants. +func makeCA(t *testing.T, keyKind string, notAfter time.Time) (certPEM, keyPEM []byte) { + t.Helper() + var pub any + var signer any + var keyBlock *pem.Block + switch keyKind { + case "rsa-pkcs1": + key, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatal(err) + } + pub, signer = key.Public(), key + keyBlock = &pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key)} + case "ec": + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatal(err) + } + der, err := x509.MarshalECPrivateKey(key) + if err != nil { + t.Fatal(err) + } + pub, signer = key.Public(), key + keyBlock = &pem.Block{Type: "EC PRIVATE KEY", Bytes: der} + case "rsa-pkcs8": + key, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatal(err) + } + der, err := x509.MarshalPKCS8PrivateKey(key) + if err != nil { + t.Fatal(err) + } + pub, signer = key.Public(), key + keyBlock = &pem.Block{Type: "PRIVATE KEY", Bytes: der} + default: + t.Fatalf("unknown keyKind %q", keyKind) + } + + tmpl := &x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{CommonName: "test-ca"}, + NotBefore: time.Now().Add(-time.Hour), + NotAfter: notAfter, + IsCA: true, + KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageDigitalSignature, + BasicConstraintsValid: true, + } + der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, pub, signer) + if err != nil { + t.Fatal(err) + } + certPEM = pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}) + keyPEM = pem.EncodeToMemory(keyBlock) + return certPEM, keyPEM +} + +func parseCertPEM(t *testing.T, certPEM []byte) *x509.Certificate { + t.Helper() + block, _ := pem.Decode(certPEM) + if block == nil { + t.Fatal("no PEM block in certificate") + } + cert, err := x509.ParseCertificate(block.Bytes) + if err != nil { + t.Fatal(err) + } + return cert +} + +func TestIssueClientCertFromCA(t *testing.T) { + for _, keyKind := range []string{"rsa-pkcs1", "ec", "rsa-pkcs8"} { + t.Run(keyKind, func(t *testing.T) { + caPEM, caKeyPEM := makeCA(t, keyKind, time.Now().Add(10*365*24*time.Hour)) + sans := []string{"replica.example.com", "replica-dr.example.com"} + + certPEM, keyPEM, err := issueClientCertFromCA("repluser", sans, caPEM, caKeyPEM) + if err != nil { + t.Fatalf("issueClientCertFromCA: %v", err) + } + + cert := parseCertPEM(t, certPEM) + caCert := parseCertPEM(t, caPEM) + + if cert.Subject.CommonName != "repluser" { + t.Errorf("CommonName = %q, want repluser", cert.Subject.CommonName) + } + if !reflect.DeepEqual(cert.DNSNames, sans) { + t.Errorf("DNSNames = %v, want %v", cert.DNSNames, sans) + } + if err := cert.CheckSignatureFrom(caCert); err != nil { + t.Errorf("client cert is not signed by the CA: %v", err) + } + hasClientAuth := false + for _, u := range cert.ExtKeyUsage { + if u == x509.ExtKeyUsageClientAuth { + hasClientAuth = true + } + } + if !hasClientAuth { + t.Error("client cert lacks ExtKeyUsage clientAuth") + } + if cert.IsCA { + t.Error("client cert must not be a CA") + } + + // The private key must match the certificate. + keyBlock, _ := pem.Decode(keyPEM) + key, err := x509.ParsePKCS8PrivateKey(keyBlock.Bytes) + if err != nil { + t.Fatalf("parse client key: %v", err) + } + rsaKey, ok := key.(*rsa.PrivateKey) + if !ok { + t.Fatalf("client key is %T, want *rsa.PrivateKey", key) + } + if !rsaKey.PublicKey.Equal(cert.PublicKey) { + t.Error("client key does not match client cert") + } + }) + } +} + +func TestIssueClientCertValidityClampedToCA(t *testing.T) { + caExpiry := time.Now().Add(30 * 24 * time.Hour) // CA dies before the 1y default + caPEM, caKeyPEM := makeCA(t, "rsa-pkcs1", caExpiry) + + certPEM, _, err := issueClientCertFromCA("repluser", nil, caPEM, caKeyPEM) + if err != nil { + t.Fatal(err) + } + cert := parseCertPEM(t, certPEM) + if cert.NotAfter.After(caExpiry.Add(time.Minute)) { + t.Errorf("client cert NotAfter %v outlives CA NotAfter %v", cert.NotAfter, caExpiry) + } +} + +func TestIssueClientCertRejectsMismatchedKey(t *testing.T) { + caPEM, _ := makeCA(t, "rsa-pkcs1", time.Now().Add(24*time.Hour)) + _, otherKeyPEM := makeCA(t, "rsa-pkcs1", time.Now().Add(24*time.Hour)) + + if _, _, err := issueClientCertFromCA("repluser", nil, caPEM, otherKeyPEM); err == nil { + t.Fatal("expected error for CA cert/key mismatch, got nil") + } +} + +func TestIssueClientCertRejectsNonCA(t *testing.T) { + // Build a leaf (IsCA=false) and try to use it as the CA. + caPEM, caKeyPEM := makeCA(t, "rsa-pkcs1", time.Now().Add(24*time.Hour)) + leafPEM, leafKeyPEM, err := issueClientCertFromCA("leaf", nil, caPEM, caKeyPEM) + if err != nil { + t.Fatal(err) + } + if _, _, err := issueClientCertFromCA("repluser", nil, leafPEM, leafKeyPEM); err == nil { + t.Fatal("expected error when --ca-cert is not a CA, got nil") + } +} + +func TestIssueClientCertRejectsGarbage(t *testing.T) { + if _, _, err := issueClientCertFromCA("u", nil, []byte("not pem"), []byte("not pem")); err == nil { + t.Fatal("expected error for garbage PEM, got nil") + } +} diff --git a/pkg/remote_replica/postgres.go b/pkg/remote_replica/postgres.go index b481d8e8c..5e2bf9a79 100644 --- a/pkg/remote_replica/postgres.go +++ b/pkg/remote_replica/postgres.go @@ -18,10 +18,12 @@ package remote_replica import ( "context" + "encoding/json" "errors" "fmt" "log" "os" + "strconv" "strings" "time" @@ -52,7 +54,10 @@ import ( ) func PostgreSQlAPP(f cmdutil.Factory) *cobra.Command { - var userName, password, dns, ns string + var userName, password, dns, ns, authSecretName, replicaName string + var caCertPath, caKeyPath string + var clientSANs []string + var port int32 var yes bool cmd := cobra.Command{ @@ -68,9 +73,28 @@ func PostgreSQlAPP(f cmdutil.Factory) *cobra.Command { if err := userPrompt(yes); err != nil { log.Fatal(err) } + // Issuing a certificate needs the CA's PRIVATE key; ca.crt alone cannot + // sign anything, so the pair travels together. + if (caCertPath == "") != (caKeyPath == "") { + log.Fatal("--ca-cert and --ca-key must be provided together") + } + + // Accept -d host:port as a convenience. An explicit --port always wins. + if host, p, found := strings.Cut(dns, ":"); found && strings.Count(dns, ":") == 1 { + if v, convErr := strconv.Atoi(p); convErr == nil && v > 0 && v < 65536 { + if !cmd.Flags().Changed("port") { + port = int32(v) + } + dns = host + } + } var buffer []byte - buffer, err := generateConfig(f, userName, password, dns, ns, args[0]) + buffer, err := generateConfig(f, userName, password, dns, ns, authSecretName, replicaName, port, args[0], tlsIssueOptions{ + CACertPath: caCertPath, + CAKeyPath: caKeyPath, + DNSSANs: clientSANs, + }) if err != nil { log.Fatal(err) } @@ -107,10 +131,25 @@ func PostgreSQlAPP(f cmdutil.Factory) *cobra.Command { log.Fatal(err) } cmd.PersistentFlags().BoolVarP(&yes, "yes", "y", false, "permission for alter password for the remote replica") + cmd.PersistentFlags().StringVar(&authSecretName, "auth-secret", "", "name for the auth secret on the remote cluster (default: -remote-replica-auth)") + cmd.PersistentFlags().Int32Var(&port, "port", 5432, "port the source is reachable on from the remote cluster; written into the generated AppBinding (also accepted as -d host:port)") + cmd.PersistentFlags().StringVar(&replicaName, "replica-name", "", "when set, also emit a ready-to-apply remote replica Postgres manifest with this name, sized from the source spec") + cmd.PersistentFlags().StringVar(&caCertPath, "ca-cert", "", "path to a CA certificate PEM; when set (together with --ca-key) the client certificate is issued locally from this CA instead of through cert-manager") + cmd.PersistentFlags().StringVar(&caKeyPath, "ca-key", "", "path to the CA private key PEM matching --ca-cert; required to sign the client certificate") + cmd.PersistentFlags().StringSliceVar(&clientSANs, "client-sans", nil, "comma separated DNS names to set as SANs on the generated client certificate") return &cmd } -func generateConfig(f cmdutil.Factory, userName string, password string, dns string, ns string, dbname string) ([]byte, error) { +// tlsIssueOptions carries how the client certificate for the remote replica is +// obtained: locally signed from a user-supplied CA pair, or issued by +// cert-manager on the source cluster (the default when the source has TLS). +type tlsIssueOptions struct { + CACertPath string + CAKeyPath string + DNSSANs []string +} + +func generateConfig(f cmdutil.Factory, userName string, password string, dns string, ns string, authSecretName string, replicaName string, port int32, dbname string, tlsOpt tlsIssueOptions) ([]byte, error) { var buffer []byte opts, err := common.NewPostgresOpts(f, dbname, ns) if err != nil { @@ -123,41 +162,160 @@ func generateConfig(f cmdutil.Factory, userName string, password string, dns str return nil, fmt.Errorf("failed to get appbinding %v", err) } - authBuff, authSecretName, err := generateAuthSecret(userName, password, ns, opts) + authBuff, authSecretName, err := generateAuthSecret(userName, password, ns, authSecretName, opts) if err != nil { return nil, fmt.Errorf("failed to generate auth secret ,%v", err) } buffer = append(buffer, authBuff...) - // generate secret - if apb.Spec.TLSSecret != nil { - tlsBuff, tlsSecretName, err := generateTlsSecret(userName, apb, ns, opts) + var tlsSecretName string + switch { + case tlsOpt.CACertPath != "": + // The user supplied a CA pair: sign the client certificate locally and skip + // cert-manager entirely. This also covers sources whose TLS was configured + // outside cert-manager (hardened/bring-your-own-CA setups). + var tlsBuff []byte + tlsBuff, tlsSecretName, err = generateTlsSecretFromLocalCA(userName, ns, tlsOpt.CACertPath, tlsOpt.CAKeyPath, tlsOpt.DNSSANs, opts) + if err != nil { + return nil, fmt.Errorf("failed to generate tls secret from --ca-cert: %v", err) + } + buffer = append(buffer, tlsBuff...) + case apb.Spec.TLSSecret != nil: + var tlsBuff []byte + tlsBuff, tlsSecretName, err = generateTlsSecret(userName, apb, ns, tlsOpt.DNSSANs, opts) if err != nil { return nil, fmt.Errorf("failed to generate tls secret %v", err) } buffer = append(buffer, tlsBuff...) - apb.Spec.TLSSecret.Name = tlsSecretName } - apb.APIVersion = AppcatApiVersion - apb.Kind = AppcatKind - apb.Spec.ClientConfig.Service.Name = dns - apb.Spec.Secret.Name = authSecretName - apb.Annotations = nil - apb.ManagedFields = nil - apb.OwnerReferences = nil + // Deep-copy the source AppBinding and replace only the ObjectMeta with a + // clean one. This preserves all spec fields (appRef, parameters, type, + // version, clientConfig, etc.) so nothing is silently dropped, while + // ensuring server-managed metadata (resourceVersion, uid, generation, + // labels, annotations) never leaks into the generated YAML. A clean + // ObjectMeta means the last-applied annotation stays minimal, so + // repeated kubectl apply is idempotent and the 3-way merge never tries + // to remove metadata.resourceVersion. + remoteApb := apb.DeepCopy() + remoteApb.TypeMeta = metav1.TypeMeta{ + APIVersion: AppcatApiVersion, + Kind: AppcatKind, + } + remoteApb.ObjectMeta = metav1.ObjectMeta{ + Name: apb.Name, + Namespace: ns, + } + if remoteApb.Spec.ClientConfig.Service == nil { + remoteApb.Spec.ClientConfig.Service = &appApi.ServiceReference{} + } + remoteApb.Spec.ClientConfig.Service.Name = dns + // The port the source is reachable on FROM THE REMOTE CLUSTER (a load balancer + // frontend, not necessarily 5432). The operator injects it as PRIMARY_PORT into + // the remote replica containers. + remoteApb.Spec.ClientConfig.Service.Port = port + if remoteApb.Spec.Secret == nil { + remoteApb.Spec.Secret = &appApi.TypedLocalObjectReference{} + } + remoteApb.Spec.Secret.Name = authSecretName + if tlsSecretName != "" { + if remoteApb.Spec.TLSSecret == nil { + remoteApb.Spec.TLSSecret = &appApi.TypedLocalObjectReference{} + } + remoteApb.Spec.TLSSecret.Name = tlsSecretName + } - appbindingYaml, err := yaml.Marshal(apb) + appbindingYaml, err := yaml.Marshal(remoteApb) if err != nil { return nil, fmt.Errorf("failed to marshal appbind yaml %v", err) } buffer = append(buffer, appbindingYaml...) + + if replicaName != "" { + replicaYaml, err := generateReplicaSpec(opts.DB, replicaName, ns, apb.Name, authSecretName) + if err != nil { + return nil, fmt.Errorf("failed to generate replica spec %v", err) + } + buffer = append(buffer, []byte("---\n")...) + buffer = append(buffer, replicaYaml...) + } return buffer, nil } -func generateTlsSecret(userName string, apb *appApi.AppBinding, ns string, opts *common.PostgresOpts) ([]byte, string, error) { - _, err := ensureClientCert(opts, apb, opts.DB, dbapi.PostgresClientCert, userName) +// generateReplicaSpec emits a ready-to-apply remote replica Postgres manifest sized from +// the source's spec. It copies the fields that describe capacity (version, replicas, +// storage, the postgres container's resources, standby mode) and adds what a remote +// replica needs: the remoteReplica sourceRef pointing at the generated AppBinding and +// the generated auth secret. The health checker needs no tuning here — it already skips +// write checks for remote replicas on its own. +// +// Deliberately NOT copied: spec.tls (the remote cluster has its own issuer), +// spec.monitor, archiver, init, and any custom sidecars. clientAuthMode falls back from +// cert to md5, since cert auth requires the TLS stanza that is not carried over. +// deletionPolicy is set to Halt regardless of the source: a DR replica's PVCs should +// survive an accidental CR deletion. Treat the result as a starting point — a secondary +// site is often sized differently on purpose. +func generateReplicaSpec(src *dbapi.Postgres, name, ns, sourceRefName, authSecretName string) ([]byte, error) { + replica := dbapi.Postgres{} + replica.APIVersion = dbapi.SchemeGroupVersion.String() + replica.Kind = dbapi.ResourceKindPostgres + replica.Name = name + replica.Namespace = ns + + replica.Spec.Version = src.Spec.Version + replica.Spec.Replicas = src.Spec.Replicas + replica.Spec.StorageType = src.Spec.StorageType + replica.Spec.Storage = src.Spec.Storage + replica.Spec.StandbyMode = src.Spec.StandbyMode + replica.Spec.DeletionPolicy = dbapi.DeletionPolicyHalt + + replica.Spec.ClientAuthMode = src.Spec.ClientAuthMode + if replica.Spec.ClientAuthMode == dbapi.ClientAuthModeCert { + replica.Spec.ClientAuthMode = dbapi.ClientAuthModeMD5 + } + + for _, c := range src.Spec.PodTemplate.Spec.Containers { + if c.Name == "postgres" { + replica.Spec.PodTemplate.Spec.Containers = []core.Container{{ + Name: c.Name, + Resources: c.Resources, + }} + break + } + } + + replica.Spec.AuthSecret = &dbapi.SecretReference{} + replica.Spec.AuthSecret.Name = authSecretName + replica.Spec.RemoteReplica = &dbapi.RemoteReplicaSpec{ + SourceRef: core.ObjectReference{ + Name: sourceRefName, + Namespace: ns, + }, + } + + // Marshal via a map so the empty status stanza is dropped from the manifest. + jsonBytes, err := json.Marshal(replica) + if err != nil { + return nil, err + } + var m map[string]interface{} + if err := json.Unmarshal(jsonBytes, &m); err != nil { + return nil, err + } + delete(m, "status") + // healthChecker has no omitempty and would render as an empty stanza; the + // operator's defaulting fills it, and it already handles remote replicas. + if spec, ok := m["spec"].(map[string]interface{}); ok { + if hc, ok := spec["healthChecker"].(map[string]interface{}); ok && len(hc) == 0 { + delete(spec, "healthChecker") + } + } + return yaml.Marshal(m) +} + +func generateTlsSecret(userName string, apb *appApi.AppBinding, ns string, extraSANs []string, opts *common.PostgresOpts) ([]byte, string, error) { + _, err := ensureClientCert(opts, apb, opts.DB, dbapi.PostgresClientCert, userName, extraSANs) if err != nil { return nil, "", fmt.Errorf("failed to ensure client cert %v", err) } @@ -180,7 +338,11 @@ func generateTlsSecret(userName string, apb *appApi.AppBinding, ns string, opts } tlsSecret.APIVersion = "v1" tlsSecret.Kind = "Secret" + tlsSecret.ResourceVersion = "" + tlsSecret.UID = "" + tlsSecret.CreationTimestamp = metav1.Time{} tlsSecret.Annotations = nil + tlsSecret.Labels = nil tlsSecret.ManagedFields = nil tlsSecretYaml, err := yaml.Marshal(tlsSecret) if err != nil { @@ -194,7 +356,7 @@ func generateTlsSecret(userName string, apb *appApi.AppBinding, ns string, opts return buffer, tlsSecret.Name, nil } -func generateAuthSecret(userName string, password string, ns string, opts *common.PostgresOpts) ([]byte, string, error) { +func generateAuthSecret(userName string, password string, ns string, secretName string, opts *common.PostgresOpts) ([]byte, string, error) { if userName != opts.Username { // generate user if not present err := generateUser(opts, userName, password) @@ -204,6 +366,9 @@ func generateAuthSecret(userName string, password string, ns string, opts *commo } else { password = opts.Pass } + if secretName == "" { + secretName = fmt.Sprintf("%s-remote-replica-auth", opts.DB.Name) + } // generate auth secret AuthSecret := core.Secret{ TypeMeta: metav1.TypeMeta{ @@ -211,7 +376,7 @@ func generateAuthSecret(userName string, password string, ns string, opts *commo APIVersion: ApiversionV1, }, ObjectMeta: metav1.ObjectMeta{ - Name: fmt.Sprintf("%s-remote-replica-auth", opts.DB.Name), + Name: secretName, Namespace: ns, }, StringData: map[string]string{ @@ -275,7 +440,7 @@ func generateUser(opts *common.PostgresOpts, name string, password string) error return nil } -func ensureClientCert(opts *common.PostgresOpts, apb *appApi.AppBinding, postgres *dbapi.Postgres, alias dbapi.PostgresCertificateAlias, username string) (kutil.VerbType, error) { +func ensureClientCert(opts *common.PostgresOpts, apb *appApi.AppBinding, postgres *dbapi.Postgres, alias dbapi.PostgresCertificateAlias, username string, extraSANs []string) (kutil.VerbType, error) { var duration, renewBefore *metav1.Duration var subject *cm_api.X509Subject var dnsNames, ipAddresses, uriSANs, emailSANs []string @@ -317,7 +482,7 @@ func ensureClientCert(opts *common.PostgresOpts, apb *appApi.AppBinding, postgre in.Spec.Subject = subject in.Spec.Duration = duration in.Spec.RenewBefore = renewBefore - in.Spec.DNSNames = sets.NewString(dnsNames...).List() + in.Spec.DNSNames = sets.NewString(append(dnsNames, extraSANs...)...).List() in.Spec.IPAddresses = sets.NewString(ipAddresses...).List() in.Spec.URIs = sets.NewString(uriSANs...).List() in.Spec.EmailAddresses = sets.NewString(emailSANs...).List()