From 09e3e396d6e3c4a6a0f544bccdf3210554b1f19c Mon Sep 17 00:00:00 2001 From: souravbiswassanto Date: Thu, 18 Jun 2026 17:29:34 +0600 Subject: [PATCH 01/11] fix(remote-config): strip server-managed metadata fields from generated YAML kubectl apply fails with a resource version conflict when the generated config YAML retains resourceVersion, uid, creationTimestamp, and generation fetched from the source cluster. These fields are server-assigned and must not be present in manifests applied to a different cluster. Also remove Labels from the TLS Secret: the cert-manager ownership label (controller.cert-manager.io/fao) must not be propagated to the remote cluster where cert-manager would incorrectly take ownership of the secret. Signed-off-by: souravbiswassanto --- pkg/remote_replica/mysql.go | 8 ++++++++ pkg/remote_replica/postgres.go | 8 ++++++++ 2 files changed, 16 insertions(+) diff --git a/pkg/remote_replica/mysql.go b/pkg/remote_replica/mysql.go index 3e9a25fa3..061f9c464 100644 --- a/pkg/remote_replica/mysql.go +++ b/pkg/remote_replica/mysql.go @@ -147,6 +147,10 @@ func generateMySQLConfig(f cmdutil.Factory, userName string, password string, dn apb.Kind = AppcatKind apb.Spec.ClientConfig.Service.Name = dns apb.Spec.Secret.Name = authSecretName + apb.ResourceVersion = "" + apb.UID = "" + apb.CreationTimestamp = metav1.Time{} + apb.Generation = 0 apb.Annotations = nil apb.ManagedFields = nil apb.OwnerReferences = nil @@ -184,7 +188,11 @@ func generateMySQLTlsSecret(userName string, apb *appApi.AppBinding, ns string, } tlsSecret.APIVersion = ApiversionV1 tlsSecret.Kind = KindSecret + 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 { diff --git a/pkg/remote_replica/postgres.go b/pkg/remote_replica/postgres.go index b481d8e8c..596304583 100644 --- a/pkg/remote_replica/postgres.go +++ b/pkg/remote_replica/postgres.go @@ -143,6 +143,10 @@ func generateConfig(f cmdutil.Factory, userName string, password string, dns str apb.Kind = AppcatKind apb.Spec.ClientConfig.Service.Name = dns apb.Spec.Secret.Name = authSecretName + apb.ResourceVersion = "" + apb.UID = "" + apb.CreationTimestamp = metav1.Time{} + apb.Generation = 0 apb.Annotations = nil apb.ManagedFields = nil apb.OwnerReferences = nil @@ -180,7 +184,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 { From 4e401e5ce36bce7910c743ab98209eaaccbef526 Mon Sep 17 00:00:00 2001 From: souravbiswassanto Date: Thu, 18 Jun 2026 18:55:03 +0600 Subject: [PATCH 02/11] fix(remote-config): build AppBinding from scratch to avoid 3-way merge corruption When kubectl apply was previously run with a YAML that contained resourceVersion (from the source cluster), the kubectl.kubernetes.io/ last-applied-configuration annotation stored it. On re-apply with a clean YAML (no resourceVersion), the 3-way merge treats the missing field as 'user wants to delete it' and patches metadata.resourceVersion to empty string, which the API server rejects as invalid value 0. Fix by constructing a minimal AppBinding containing only the connection fields needed by the remote replica, rather than mutating the AppBinding fetched from the source cluster. This keeps the last-applied annotation minimal and idempotent across re-runs, and avoids carrying over source-cluster labels, annotations, appRef, and Stash parameters that are irrelevant on the remote side. Signed-off-by: souravbiswassanto --- pkg/remote_replica/mysql.go | 58 ++++++++++++++++++++++++---------- pkg/remote_replica/postgres.go | 58 ++++++++++++++++++++++++---------- 2 files changed, 84 insertions(+), 32 deletions(-) diff --git a/pkg/remote_replica/mysql.go b/pkg/remote_replica/mysql.go index 061f9c464..13be96064 100644 --- a/pkg/remote_replica/mysql.go +++ b/pkg/remote_replica/mysql.go @@ -133,29 +133,55 @@ func generateMySQLConfig(f cmdutil.Factory, userName string, password string, dn } buffer = append(buffer, authBuff...) - // generate secret + var tlsSecretName string if apb.Spec.TLSSecret != nil { - tlsBuff, tlsSecretName, err := generateMySQLTlsSecret(userName, apb, ns, opts) + var tlsBuff []byte + tlsBuff, tlsSecretName, err = generateMySQLTlsSecret(userName, apb, ns, 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.ResourceVersion = "" - apb.UID = "" - apb.CreationTimestamp = metav1.Time{} - apb.Generation = 0 - apb.Annotations = nil - apb.ManagedFields = nil - apb.OwnerReferences = nil - - appbindingYaml, err := yaml.Marshal(apb) + // Build a minimal AppBinding from scratch so that no server-managed fields + // (resourceVersion, uid, generation), source-cluster labels, or unrelated + // parameters (e.g. Stash addon config) are carried over. This makes + // repeated kubectl apply idempotent: the last-applied annotation stays + // clean and the 3-way merge never tries to remove metadata.resourceVersion. + remoteApb := &appApi.AppBinding{ + TypeMeta: metav1.TypeMeta{ + APIVersion: AppcatApiVersion, + Kind: AppcatKind, + }, + ObjectMeta: metav1.ObjectMeta{ + Name: apb.Name, + Namespace: ns, + }, + Spec: appApi.AppBindingSpec{ + Type: apb.Spec.Type, + Version: apb.Spec.Version, + ClientConfig: appApi.ClientConfig{ + CABundle: apb.Spec.ClientConfig.CABundle, + Service: &appApi.ServiceReference{ + Scheme: apb.Spec.ClientConfig.Service.Scheme, + Name: dns, + Port: apb.Spec.ClientConfig.Service.Port, + Path: apb.Spec.ClientConfig.Service.Path, + Query: apb.Spec.ClientConfig.Service.Query, + }, + }, + Secret: &appApi.TypedLocalObjectReference{ + Name: authSecretName, + }, + }, + } + if tlsSecretName != "" { + remoteApb.Spec.TLSSecret = &appApi.TypedLocalObjectReference{ + Name: tlsSecretName, + } + } + + appbindingYaml, err := yaml.Marshal(remoteApb) if err != nil { return nil, fmt.Errorf("failed to marshal appbind yaml %v", err) } diff --git a/pkg/remote_replica/postgres.go b/pkg/remote_replica/postgres.go index 596304583..a9ab0288d 100644 --- a/pkg/remote_replica/postgres.go +++ b/pkg/remote_replica/postgres.go @@ -129,29 +129,55 @@ func generateConfig(f cmdutil.Factory, userName string, password string, dns str } buffer = append(buffer, authBuff...) - // generate secret + var tlsSecretName string if apb.Spec.TLSSecret != nil { - tlsBuff, tlsSecretName, err := generateTlsSecret(userName, apb, ns, opts) + var tlsBuff []byte + tlsBuff, tlsSecretName, err = generateTlsSecret(userName, apb, ns, 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.ResourceVersion = "" - apb.UID = "" - apb.CreationTimestamp = metav1.Time{} - apb.Generation = 0 - apb.Annotations = nil - apb.ManagedFields = nil - apb.OwnerReferences = nil - - appbindingYaml, err := yaml.Marshal(apb) + // Build a minimal AppBinding from scratch so that no server-managed fields + // (resourceVersion, uid, generation), source-cluster labels, or unrelated + // parameters (e.g. Stash addon config) are carried over. This makes + // repeated kubectl apply idempotent: the last-applied annotation stays + // clean and the 3-way merge never tries to remove metadata.resourceVersion. + remoteApb := &appApi.AppBinding{ + TypeMeta: metav1.TypeMeta{ + APIVersion: AppcatApiVersion, + Kind: AppcatKind, + }, + ObjectMeta: metav1.ObjectMeta{ + Name: apb.Name, + Namespace: ns, + }, + Spec: appApi.AppBindingSpec{ + Type: apb.Spec.Type, + Version: apb.Spec.Version, + ClientConfig: appApi.ClientConfig{ + CABundle: apb.Spec.ClientConfig.CABundle, + Service: &appApi.ServiceReference{ + Scheme: apb.Spec.ClientConfig.Service.Scheme, + Name: dns, + Port: apb.Spec.ClientConfig.Service.Port, + Path: apb.Spec.ClientConfig.Service.Path, + Query: apb.Spec.ClientConfig.Service.Query, + }, + }, + Secret: &appApi.TypedLocalObjectReference{ + Name: authSecretName, + }, + }, + } + if tlsSecretName != "" { + remoteApb.Spec.TLSSecret = &appApi.TypedLocalObjectReference{ + Name: tlsSecretName, + } + } + + appbindingYaml, err := yaml.Marshal(remoteApb) if err != nil { return nil, fmt.Errorf("failed to marshal appbind yaml %v", err) } From 8d4ff96cca4a924cb8c3560b922e54f8fff93cb9 Mon Sep 17 00:00:00 2001 From: souravbiswassanto Date: Thu, 18 Jun 2026 18:59:16 +0600 Subject: [PATCH 03/11] refactor(remote-config): use DeepCopy instead of building AppBinding from scratch Building the AppBinding from scratch drops fields that exist in the source AppBinding spec (appRef, parameters, secretTransforms, and any future additions). Using DeepCopy + clean ObjectMeta is safer: the full spec is preserved while server-managed metadata is discarded. Signed-off-by: souravbiswassanto --- pkg/remote_replica/mysql.go | 53 ++++++++++++---------------------- pkg/remote_replica/postgres.go | 53 ++++++++++++---------------------- 2 files changed, 38 insertions(+), 68 deletions(-) diff --git a/pkg/remote_replica/mysql.go b/pkg/remote_replica/mysql.go index 13be96064..fb7c1703d 100644 --- a/pkg/remote_replica/mysql.go +++ b/pkg/remote_replica/mysql.go @@ -143,42 +143,27 @@ func generateMySQLConfig(f cmdutil.Factory, userName string, password string, dn buffer = append(buffer, tlsBuff...) } - // Build a minimal AppBinding from scratch so that no server-managed fields - // (resourceVersion, uid, generation), source-cluster labels, or unrelated - // parameters (e.g. Stash addon config) are carried over. This makes - // repeated kubectl apply idempotent: the last-applied annotation stays - // clean and the 3-way merge never tries to remove metadata.resourceVersion. - remoteApb := &appApi.AppBinding{ - TypeMeta: metav1.TypeMeta{ - APIVersion: AppcatApiVersion, - Kind: AppcatKind, - }, - ObjectMeta: metav1.ObjectMeta{ - Name: apb.Name, - Namespace: ns, - }, - Spec: appApi.AppBindingSpec{ - Type: apb.Spec.Type, - Version: apb.Spec.Version, - ClientConfig: appApi.ClientConfig{ - CABundle: apb.Spec.ClientConfig.CABundle, - Service: &appApi.ServiceReference{ - Scheme: apb.Spec.ClientConfig.Service.Scheme, - Name: dns, - Port: apb.Spec.ClientConfig.Service.Port, - Path: apb.Spec.ClientConfig.Service.Path, - Query: apb.Spec.ClientConfig.Service.Query, - }, - }, - Secret: &appApi.TypedLocalObjectReference{ - Name: authSecretName, - }, - }, + // 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, + } + remoteApb.Spec.ClientConfig.Service.Name = dns + remoteApb.Spec.Secret.Name = authSecretName if tlsSecretName != "" { - remoteApb.Spec.TLSSecret = &appApi.TypedLocalObjectReference{ - Name: tlsSecretName, - } + remoteApb.Spec.TLSSecret.Name = tlsSecretName } appbindingYaml, err := yaml.Marshal(remoteApb) diff --git a/pkg/remote_replica/postgres.go b/pkg/remote_replica/postgres.go index a9ab0288d..03f1e2918 100644 --- a/pkg/remote_replica/postgres.go +++ b/pkg/remote_replica/postgres.go @@ -139,42 +139,27 @@ func generateConfig(f cmdutil.Factory, userName string, password string, dns str buffer = append(buffer, tlsBuff...) } - // Build a minimal AppBinding from scratch so that no server-managed fields - // (resourceVersion, uid, generation), source-cluster labels, or unrelated - // parameters (e.g. Stash addon config) are carried over. This makes - // repeated kubectl apply idempotent: the last-applied annotation stays - // clean and the 3-way merge never tries to remove metadata.resourceVersion. - remoteApb := &appApi.AppBinding{ - TypeMeta: metav1.TypeMeta{ - APIVersion: AppcatApiVersion, - Kind: AppcatKind, - }, - ObjectMeta: metav1.ObjectMeta{ - Name: apb.Name, - Namespace: ns, - }, - Spec: appApi.AppBindingSpec{ - Type: apb.Spec.Type, - Version: apb.Spec.Version, - ClientConfig: appApi.ClientConfig{ - CABundle: apb.Spec.ClientConfig.CABundle, - Service: &appApi.ServiceReference{ - Scheme: apb.Spec.ClientConfig.Service.Scheme, - Name: dns, - Port: apb.Spec.ClientConfig.Service.Port, - Path: apb.Spec.ClientConfig.Service.Path, - Query: apb.Spec.ClientConfig.Service.Query, - }, - }, - Secret: &appApi.TypedLocalObjectReference{ - Name: authSecretName, - }, - }, + // 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, + } + remoteApb.Spec.ClientConfig.Service.Name = dns + remoteApb.Spec.Secret.Name = authSecretName if tlsSecretName != "" { - remoteApb.Spec.TLSSecret = &appApi.TypedLocalObjectReference{ - Name: tlsSecretName, - } + remoteApb.Spec.TLSSecret.Name = tlsSecretName } appbindingYaml, err := yaml.Marshal(remoteApb) From 97976dbfc09f924218de1fac72825f59f31bb1df Mon Sep 17 00:00:00 2001 From: souravbiswassanto Date: Thu, 18 Jun 2026 19:07:26 +0600 Subject: [PATCH 04/11] fix(remote-config): guard against nil Service and Secret pointers before dereference ClientConfig.Service (*ServiceReference) and Secret (*TypedLocalObjectReference) are optional pointer fields. If the source AppBinding uses clientConfig.url instead of clientConfig.service, or has no secret set, the DeepCopy would carry nil pointers and the subsequent .Name assignments would panic. Signed-off-by: souravbiswassanto --- pkg/remote_replica/mysql.go | 9 +++++++++ pkg/remote_replica/postgres.go | 9 +++++++++ 2 files changed, 18 insertions(+) diff --git a/pkg/remote_replica/mysql.go b/pkg/remote_replica/mysql.go index fb7c1703d..b4f2fc48e 100644 --- a/pkg/remote_replica/mysql.go +++ b/pkg/remote_replica/mysql.go @@ -160,9 +160,18 @@ func generateMySQLConfig(f cmdutil.Factory, userName string, password string, dn Name: apb.Name, Namespace: ns, } + if remoteApb.Spec.ClientConfig.Service == nil { + remoteApb.Spec.ClientConfig.Service = &appApi.ServiceReference{} + } remoteApb.Spec.ClientConfig.Service.Name = dns + 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 } diff --git a/pkg/remote_replica/postgres.go b/pkg/remote_replica/postgres.go index 03f1e2918..602018d23 100644 --- a/pkg/remote_replica/postgres.go +++ b/pkg/remote_replica/postgres.go @@ -156,9 +156,18 @@ func generateConfig(f cmdutil.Factory, userName string, password string, dns str Name: apb.Name, Namespace: ns, } + if remoteApb.Spec.ClientConfig.Service == nil { + remoteApb.Spec.ClientConfig.Service = &appApi.ServiceReference{} + } remoteApb.Spec.ClientConfig.Service.Name = dns + 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 } From 74f2e3c9c631458bb05236b1dfbee4a013b4226d Mon Sep 17 00:00:00 2001 From: souravbiswassanto Date: Wed, 24 Jun 2026 12:29:26 +0600 Subject: [PATCH 05/11] remove mysql changes Signed-off-by: souravbiswassanto --- pkg/remote_replica/mysql.go | 50 ++++++++----------------------------- 1 file changed, 11 insertions(+), 39 deletions(-) diff --git a/pkg/remote_replica/mysql.go b/pkg/remote_replica/mysql.go index b4f2fc48e..3e9a25fa3 100644 --- a/pkg/remote_replica/mysql.go +++ b/pkg/remote_replica/mysql.go @@ -133,49 +133,25 @@ func generateMySQLConfig(f cmdutil.Factory, userName string, password string, dn } buffer = append(buffer, authBuff...) - var tlsSecretName string + // generate secret if apb.Spec.TLSSecret != nil { - var tlsBuff []byte - tlsBuff, tlsSecretName, err = generateMySQLTlsSecret(userName, apb, ns, opts) + tlsBuff, tlsSecretName, err := generateMySQLTlsSecret(userName, apb, ns, opts) if err != nil { return nil, fmt.Errorf("failed to generate tls secret %v", err) } buffer = append(buffer, tlsBuff...) + apb.Spec.TLSSecret.Name = tlsSecretName } - // 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 - 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 - } + 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 - appbindingYaml, err := yaml.Marshal(remoteApb) + appbindingYaml, err := yaml.Marshal(apb) if err != nil { return nil, fmt.Errorf("failed to marshal appbind yaml %v", err) } @@ -208,11 +184,7 @@ func generateMySQLTlsSecret(userName string, apb *appApi.AppBinding, ns string, } tlsSecret.APIVersion = ApiversionV1 tlsSecret.Kind = KindSecret - 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 { From ddcf1e0e2232d3e6d0ae2b6e60ea4fa2543f4f6d Mon Sep 17 00:00:00 2001 From: souravbiswassanto Date: Wed, 24 Jun 2026 14:41:46 +0600 Subject: [PATCH 06/11] feat(remote-config): add -s/--auth-secret flag to override auth secret name If --auth-secret is provided, the generated auth Secret and the AppBinding's secret reference both use that name. If omitted, the default -remote-replica-auth is used as before. Signed-off-by: souravbiswassanto --- pkg/remote_replica/postgres.go | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/pkg/remote_replica/postgres.go b/pkg/remote_replica/postgres.go index 602018d23..77dfc6994 100644 --- a/pkg/remote_replica/postgres.go +++ b/pkg/remote_replica/postgres.go @@ -52,7 +52,7 @@ import ( ) func PostgreSQlAPP(f cmdutil.Factory) *cobra.Command { - var userName, password, dns, ns string + var userName, password, dns, ns, authSecretName string var yes bool cmd := cobra.Command{ @@ -70,7 +70,7 @@ func PostgreSQlAPP(f cmdutil.Factory) *cobra.Command { } var buffer []byte - buffer, err := generateConfig(f, userName, password, dns, ns, args[0]) + buffer, err := generateConfig(f, userName, password, dns, ns, authSecretName, args[0]) if err != nil { log.Fatal(err) } @@ -107,10 +107,11 @@ 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().StringVarP(&authSecretName, "auth-secret", "s", "", "name for the auth secret on the remote cluster (default: -remote-replica-auth)") return &cmd } -func generateConfig(f cmdutil.Factory, userName string, password string, dns string, ns string, dbname string) ([]byte, error) { +func generateConfig(f cmdutil.Factory, userName string, password string, dns string, ns string, authSecretName string, dbname string) ([]byte, error) { var buffer []byte opts, err := common.NewPostgresOpts(f, dbname, ns) if err != nil { @@ -123,7 +124,7 @@ 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) } @@ -222,7 +223,8 @@ 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) { + var buffer []byte if userName != opts.Username { // generate user if not present err := generateUser(opts, userName, password) @@ -232,6 +234,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{ @@ -239,7 +244,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{ From 755769742913c290aa95a09341eca0b7d81211b3 Mon Sep 17 00:00:00 2001 From: souravbiswassanto Date: Wed, 24 Jun 2026 15:41:04 +0600 Subject: [PATCH 07/11] fix(remote-config): drop -s shorthand from --auth-secret flag -s is reserved by k8s.io/cli-runtime for --server (the Kubernetes API server address) and is registered as a root persistent flag inherited by all subcommands. Using the same shorthand causes a panic in cobra's mergePersistentFlags. Use the long form --auth-secret only. Signed-off-by: souravbiswassanto --- pkg/remote_replica/postgres.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/remote_replica/postgres.go b/pkg/remote_replica/postgres.go index 77dfc6994..bf320d034 100644 --- a/pkg/remote_replica/postgres.go +++ b/pkg/remote_replica/postgres.go @@ -107,7 +107,7 @@ 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().StringVarP(&authSecretName, "auth-secret", "s", "", "name for the auth secret on the remote cluster (default: -remote-replica-auth)") + cmd.PersistentFlags().StringVar(&authSecretName, "auth-secret", "", "name for the auth secret on the remote cluster (default: -remote-replica-auth)") return &cmd } From 7fe9ed640d5fdfd787bbb0a2cc12380917b80a58 Mon Sep 17 00:00:00 2001 From: Tamal Saha Date: Tue, 4 Aug 2026 12:22:41 +0600 Subject: [PATCH 08/11] Fix build: drop redeclared buffer in generateAuthSecret MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit generateAuthSecret declared `var buffer []byte` at the top of the function and then redeclared it with `:=` after marshalling the secret, which fails to compile: pkg/remote_replica/postgres.go:261:9: no new variables on left side of := The leading declaration is dead — buffer is not referenced before the `make` call — so remove it and keep the sized allocation. Signed-off-by: Tamal Saha --- pkg/remote_replica/postgres.go | 1 - 1 file changed, 1 deletion(-) diff --git a/pkg/remote_replica/postgres.go b/pkg/remote_replica/postgres.go index bf320d034..d7c9c75d4 100644 --- a/pkg/remote_replica/postgres.go +++ b/pkg/remote_replica/postgres.go @@ -224,7 +224,6 @@ func generateTlsSecret(userName string, apb *appApi.AppBinding, ns string, opts } func generateAuthSecret(userName string, password string, ns string, secretName string, opts *common.PostgresOpts) ([]byte, string, error) { - var buffer []byte if userName != opts.Username { // generate user if not present err := generateUser(opts, userName, password) From 66e2096903cf1bb08a6627cbda1c187042b87ff8 Mon Sep 17 00:00:00 2001 From: Tamal Saha Date: Wed, 5 Aug 2026 11:08:06 +0600 Subject: [PATCH 09/11] remote-config: add --port / -d host:port and --replica-name --port (default 5432, also accepted as -d host:port; an explicit --port wins) is written into the generated AppBinding's spec.clientConfig.service.port. Sources behind a load balancer are commonly exposed on non-standard frontend ports; the operator injects this as PRIMARY_PORT into the remote replica containers. --replica-name, when set, appends a ready-to-apply remote replica Postgres manifest to the generated config, sized from the source spec: version, replicas, storage, storageType, standby mode and the postgres container's resources are copied; remoteReplica.sourceRef, the generated auth secret and a disabled write check are added. spec.tls, monitoring, archiver and custom sidecars are deliberately not carried over (the remote cluster has its own issuer), clientAuthMode falls back from cert to md5 for the same reason, and deletionPolicy is forced to Halt so a DR replica's PVCs survive accidental CR deletion. The result is a starting point; a secondary site is often sized differently on purpose. Verified end to end: one command against a live TLS source exposed on port 5434, one kubectl apply on the remote cluster creating secrets, AppBinding and Postgres CR; the replica reached Ready streaming over port 5434 with the copied resources in place. Signed-off-by: Tamal Saha --- pkg/remote_replica/postgres.go | 98 ++++++++++++++++++++++++++++++++-- 1 file changed, 95 insertions(+), 3 deletions(-) diff --git a/pkg/remote_replica/postgres.go b/pkg/remote_replica/postgres.go index d7c9c75d4..653775899 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,8 @@ import ( ) func PostgreSQlAPP(f cmdutil.Factory) *cobra.Command { - var userName, password, dns, ns, authSecretName string + var userName, password, dns, ns, authSecretName, replicaName string + var port int32 var yes bool cmd := cobra.Command{ @@ -69,8 +72,18 @@ func PostgreSQlAPP(f cmdutil.Factory) *cobra.Command { log.Fatal(err) } + // 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, authSecretName, args[0]) + buffer, err := generateConfig(f, userName, password, dns, ns, authSecretName, replicaName, port, args[0]) if err != nil { log.Fatal(err) } @@ -108,10 +121,12 @@ func PostgreSQlAPP(f cmdutil.Factory) *cobra.Command { } 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") return &cmd } -func generateConfig(f cmdutil.Factory, userName string, password string, dns string, ns string, authSecretName string, dbname string) ([]byte, error) { +func generateConfig(f cmdutil.Factory, userName string, password string, dns string, ns string, authSecretName string, replicaName string, port int32, dbname string) ([]byte, error) { var buffer []byte opts, err := common.NewPostgresOpts(f, dbname, ns) if err != nil { @@ -161,6 +176,10 @@ func generateConfig(f cmdutil.Factory, userName string, password string, dns str 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{} } @@ -178,9 +197,82 @@ func generateConfig(f cmdutil.Factory, userName string, password string, dns str } 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 } +// 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, the +// generated auth secret, and a disabled write check (a hot standby is read-only). +// +// 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, + }, + } + replica.Spec.HealthChecker.DisableWriteCheck = true + + // 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") + return yaml.Marshal(m) +} + func generateTlsSecret(userName string, apb *appApi.AppBinding, ns string, opts *common.PostgresOpts) ([]byte, string, error) { _, err := ensureClientCert(opts, apb, opts.DB, dbapi.PostgresClientCert, userName) if err != nil { From c8dc58389931b16cc3497dbec2221b1c1deed5a4 Mon Sep 17 00:00:00 2001 From: souravbiswassanto Date: Fri, 7 Aug 2026 20:10:04 +0600 Subject: [PATCH 10/11] add fix Signed-off-by: souravbiswassanto --- pkg/remote_replica/postgres.go | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/pkg/remote_replica/postgres.go b/pkg/remote_replica/postgres.go index 653775899..ad9b017ca 100644 --- a/pkg/remote_replica/postgres.go +++ b/pkg/remote_replica/postgres.go @@ -212,8 +212,9 @@ func generateConfig(f cmdutil.Factory, userName string, password string, dns str // 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, the -// generated auth secret, and a disabled write check (a hot standby is read-only). +// 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 @@ -258,7 +259,6 @@ func generateReplicaSpec(src *dbapi.Postgres, name, ns, sourceRefName, authSecre Namespace: ns, }, } - replica.Spec.HealthChecker.DisableWriteCheck = true // Marshal via a map so the empty status stanza is dropped from the manifest. jsonBytes, err := json.Marshal(replica) @@ -270,6 +270,13 @@ func generateReplicaSpec(src *dbapi.Postgres, name, ns, sourceRefName, authSecre 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) } From 975c688a0f222c537739e4a3a2d2793cdec8febf Mon Sep 17 00:00:00 2001 From: Tamal Saha Date: Fri, 7 Aug 2026 20:36:15 +0600 Subject: [PATCH 11/11] remote-config: issue client cert locally from a user-supplied CA Add --ca-cert/--ca-key (required together: signing needs the CA's private key, ca.crt alone cannot issue anything). When given, the client certificate is generated and signed locally instead of through cert-manager, covering sources whose TLS is not cert-manager-managed. CN is the replication user, validity is clamped to the CA's own expiry, and the emitted kubernetes.io/tls Secret carries ca.crt/tls.crt/tls.key exactly as the operator expects to mount them. Add --client-sans (comma separated DNS names) applied to the generated certificate on both the local-CA and cert-manager paths. Signed-off-by: Tamal Saha --- pkg/remote_replica/local_ca.go | 179 +++++++++++++++++++++++++ pkg/remote_replica/local_ca_test.go | 197 ++++++++++++++++++++++++++++ pkg/remote_replica/postgres.go | 50 +++++-- 3 files changed, 418 insertions(+), 8 deletions(-) create mode 100644 pkg/remote_replica/local_ca.go create mode 100644 pkg/remote_replica/local_ca_test.go 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 ad9b017ca..5e2bf9a79 100644 --- a/pkg/remote_replica/postgres.go +++ b/pkg/remote_replica/postgres.go @@ -55,6 +55,8 @@ import ( func PostgreSQlAPP(f cmdutil.Factory) *cobra.Command { var userName, password, dns, ns, authSecretName, replicaName string + var caCertPath, caKeyPath string + var clientSANs []string var port int32 var yes bool @@ -71,6 +73,11 @@ 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 { @@ -83,7 +90,11 @@ func PostgreSQlAPP(f cmdutil.Factory) *cobra.Command { } var buffer []byte - buffer, err := generateConfig(f, userName, password, dns, ns, authSecretName, replicaName, port, 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) } @@ -123,10 +134,22 @@ func PostgreSQlAPP(f cmdutil.Factory) *cobra.Command { 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, authSecretName string, replicaName string, port int32, 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 { @@ -146,9 +169,20 @@ func generateConfig(f cmdutil.Factory, userName string, password string, dns str buffer = append(buffer, authBuff...) var tlsSecretName string - if apb.Spec.TLSSecret != nil { + 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, opts) + tlsBuff, tlsSecretName, err = generateTlsSecret(userName, apb, ns, tlsOpt.DNSSANs, opts) if err != nil { return nil, fmt.Errorf("failed to generate tls secret %v", err) } @@ -280,8 +314,8 @@ func generateReplicaSpec(src *dbapi.Postgres, name, ns, sourceRefName, authSecre return yaml.Marshal(m) } -func generateTlsSecret(userName string, apb *appApi.AppBinding, ns string, opts *common.PostgresOpts) ([]byte, string, error) { - _, err := ensureClientCert(opts, apb, opts.DB, dbapi.PostgresClientCert, userName) +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) } @@ -406,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 @@ -448,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()