diff --git a/build/crd/percona/generated/pgv2.percona.com_perconapgclusters.yaml b/build/crd/percona/generated/pgv2.percona.com_perconapgclusters.yaml index d600b02bbb..9132f12648 100644 --- a/build/crd/percona/generated/pgv2.percona.com_perconapgclusters.yaml +++ b/build/crd/percona/generated/pgv2.percona.com_perconapgclusters.yaml @@ -28734,6 +28734,26 @@ spec: type: string certValidityDuration: type: string + issuerConf: + description: IssuerReference is a reference to a certificate issuer + object with a given name, kind and group. + properties: + group: + description: |- + Group of the issuer being referred to. + Defaults to 'cert-manager.io'. + type: string + kind: + description: |- + Kind of the issuer being referred to. + Defaults to 'Issuer'. + type: string + name: + description: Name of the issuer being referred to. + type: string + required: + - name + type: object pgBackRestCertValidityDuration: type: string type: object diff --git a/cmd/postgres-operator/main.go b/cmd/postgres-operator/main.go index bf7ce2dfe3..537dc8d851 100644 --- a/cmd/postgres-operator/main.go +++ b/cmd/postgres-operator/main.go @@ -125,15 +125,6 @@ func main() { ) assertNoError(err) - // Add Percona custom resource types to scheme - assertNoError(v2.AddToScheme(mgr.GetScheme())) - - assertNoError(volumesnapshotv1.AddToScheme(mgr.GetScheme())) - - // K8SPG-552 - // Add Scheme for cert-manager resources like Issuer and Certificate. - assertNoError(certmanagerscheme.AddToScheme(mgr.GetScheme())) - // add all PostgreSQL Operator controllers to the runtime manager err = addControllersToManager(ctx, mgr) assertNoError(err) @@ -367,6 +358,13 @@ func initManager(ctx context.Context) (runtime.Options, error) { } } + // add scheme + scheme := runtime.Scheme + assertNoError(v2.AddToScheme(scheme)) + assertNoError(volumesnapshotv1.AddToScheme(scheme)) + assertNoError(certmanagerscheme.AddToScheme(scheme)) + options.Scheme = scheme + return options, nil } diff --git a/cmd/postgres-operator/main_test.go b/cmd/postgres-operator/main_test.go index 4e04c4af2a..690b3db2a8 100644 --- a/cmd/postgres-operator/main_test.go +++ b/cmd/postgres-operator/main_test.go @@ -11,6 +11,8 @@ import ( "gotest.tools/v3/assert" "gotest.tools/v3/assert/cmp" + + "github.com/percona/percona-postgresql-operator/v2/internal/controller/runtime" ) func TestInitManager(t *testing.T) { @@ -42,6 +44,9 @@ func TestInitManager(t *testing.T) { assert.Assert(t, options.RenewDeadline.Seconds() == 40) assert.Assert(t, options.RetryPeriod.Seconds() == 10) + assert.Assert(t, options.Scheme == runtime.Scheme, + "expected the shared scheme to be configured before manager creation") + { options.Cache.SyncPeriod = nil options.Controller.GroupKindConcurrency = nil @@ -51,6 +56,7 @@ func TestInitManager(t *testing.T) { options.LeaseDuration = nil options.RenewDeadline = nil options.RetryPeriod = nil + options.Scheme = nil assert.Assert(t, reflect.ValueOf(options).IsZero(), "expected remaining fields to be unset:\n%+v", options) diff --git a/config/crd/bases/pgv2.percona.com_perconapgclusters.yaml b/config/crd/bases/pgv2.percona.com_perconapgclusters.yaml index 2ff064e994..c0a165b300 100644 --- a/config/crd/bases/pgv2.percona.com_perconapgclusters.yaml +++ b/config/crd/bases/pgv2.percona.com_perconapgclusters.yaml @@ -29432,6 +29432,26 @@ spec: type: string certValidityDuration: type: string + issuerConf: + description: IssuerReference is a reference to a certificate issuer + object with a given name, kind and group. + properties: + group: + description: |- + Group of the issuer being referred to. + Defaults to 'cert-manager.io'. + type: string + kind: + description: |- + Kind of the issuer being referred to. + Defaults to 'Issuer'. + type: string + name: + description: Name of the issuer being referred to. + type: string + required: + - name + type: object pgBackRestCertValidityDuration: type: string type: object diff --git a/config/crd/bases/upstream.pgv2.percona.com_postgresclusters.yaml b/config/crd/bases/upstream.pgv2.percona.com_postgresclusters.yaml index 63c7ec1cf2..aef40e758b 100644 --- a/config/crd/bases/upstream.pgv2.percona.com_postgresclusters.yaml +++ b/config/crd/bases/upstream.pgv2.percona.com_postgresclusters.yaml @@ -28709,6 +28709,26 @@ spec: type: string certValidityDuration: type: string + issuerConf: + description: IssuerReference is a reference to a certificate issuer + object with a given name, kind and group. + properties: + group: + description: |- + Group of the issuer being referred to. + Defaults to 'cert-manager.io'. + type: string + kind: + description: |- + Kind of the issuer being referred to. + Defaults to 'Issuer'. + type: string + name: + description: Name of the issuer being referred to. + type: string + required: + - name + type: object pgBackRestCertValidityDuration: type: string type: object diff --git a/deploy/bundle.yaml b/deploy/bundle.yaml index fff58aecba..034da545d9 100644 --- a/deploy/bundle.yaml +++ b/deploy/bundle.yaml @@ -29729,6 +29729,26 @@ spec: type: string certValidityDuration: type: string + issuerConf: + description: IssuerReference is a reference to a certificate issuer + object with a given name, kind and group. + properties: + group: + description: |- + Group of the issuer being referred to. + Defaults to 'cert-manager.io'. + type: string + kind: + description: |- + Kind of the issuer being referred to. + Defaults to 'Issuer'. + type: string + name: + description: Name of the issuer being referred to. + type: string + required: + - name + type: object pgBackRestCertValidityDuration: type: string type: object @@ -66826,6 +66846,26 @@ spec: type: string certValidityDuration: type: string + issuerConf: + description: IssuerReference is a reference to a certificate issuer + object with a given name, kind and group. + properties: + group: + description: |- + Group of the issuer being referred to. + Defaults to 'cert-manager.io'. + type: string + kind: + description: |- + Kind of the issuer being referred to. + Defaults to 'Issuer'. + type: string + name: + description: Name of the issuer being referred to. + type: string + required: + - name + type: object pgBackRestCertValidityDuration: type: string type: object diff --git a/deploy/cr.yaml b/deploy/cr.yaml index 07f69f42e9..5099899f9a 100644 --- a/deploy/cr.yaml +++ b/deploy/cr.yaml @@ -62,6 +62,10 @@ spec: # certValidityDuration: 2160h # caValidityDuration: 26280h # pgBackRestCertValidityDuration: 2160h +# issuerConf: +# name: some-selfsigned-issuer +# kind: ClusterIssuer +# group: cert-manager.io # standby: # enabled: true # host: "" diff --git a/deploy/crd.yaml b/deploy/crd.yaml index 340b6668f4..f2cb618879 100644 --- a/deploy/crd.yaml +++ b/deploy/crd.yaml @@ -29729,6 +29729,26 @@ spec: type: string certValidityDuration: type: string + issuerConf: + description: IssuerReference is a reference to a certificate issuer + object with a given name, kind and group. + properties: + group: + description: |- + Group of the issuer being referred to. + Defaults to 'cert-manager.io'. + type: string + kind: + description: |- + Kind of the issuer being referred to. + Defaults to 'Issuer'. + type: string + name: + description: Name of the issuer being referred to. + type: string + required: + - name + type: object pgBackRestCertValidityDuration: type: string type: object @@ -66826,6 +66846,26 @@ spec: type: string certValidityDuration: type: string + issuerConf: + description: IssuerReference is a reference to a certificate issuer + object with a given name, kind and group. + properties: + group: + description: |- + Group of the issuer being referred to. + Defaults to 'cert-manager.io'. + type: string + kind: + description: |- + Kind of the issuer being referred to. + Defaults to 'Issuer'. + type: string + name: + description: Name of the issuer being referred to. + type: string + required: + - name + type: object pgBackRestCertValidityDuration: type: string type: object diff --git a/deploy/cw-bundle.yaml b/deploy/cw-bundle.yaml index eb89883bf0..2b67891aa7 100644 --- a/deploy/cw-bundle.yaml +++ b/deploy/cw-bundle.yaml @@ -29729,6 +29729,26 @@ spec: type: string certValidityDuration: type: string + issuerConf: + description: IssuerReference is a reference to a certificate issuer + object with a given name, kind and group. + properties: + group: + description: |- + Group of the issuer being referred to. + Defaults to 'cert-manager.io'. + type: string + kind: + description: |- + Kind of the issuer being referred to. + Defaults to 'Issuer'. + type: string + name: + description: Name of the issuer being referred to. + type: string + required: + - name + type: object pgBackRestCertValidityDuration: type: string type: object @@ -66826,6 +66846,26 @@ spec: type: string certValidityDuration: type: string + issuerConf: + description: IssuerReference is a reference to a certificate issuer + object with a given name, kind and group. + properties: + group: + description: |- + Group of the issuer being referred to. + Defaults to 'cert-manager.io'. + type: string + kind: + description: |- + Kind of the issuer being referred to. + Defaults to 'Issuer'. + type: string + name: + description: Name of the issuer being referred to. + type: string + required: + - name + type: object pgBackRestCertValidityDuration: type: string type: object diff --git a/e2e-tests/functions b/e2e-tests/functions index d84e5cb8a7..febfb71c00 100644 --- a/e2e-tests/functions +++ b/e2e-tests/functions @@ -1175,6 +1175,11 @@ deploy_cert_manager() { sleep 5 done + echo "Waiting for cert-manager mutating webhook to be ready..." + until kubectl get mutatingwebhookconfiguration cert-manager-webhook -o jsonpath='{.webhooks[0].clientConfig.caBundle}' | grep -q '[A-Za-z0-9+/=]'; do + sleep 5 + done + echo "Waiting for cert-manager webhook service to have endpoints..." until kubectl -n cert-manager get endpoints cert-manager-webhook -o jsonpath='{.subsets[*].addresses}' | grep -q '.'; do sleep 5 diff --git a/e2e-tests/tests/cert-manager-tls/05-deploy-cert-manager.yaml b/e2e-tests/tests/cert-manager-tls/05-deploy-cert-manager.yaml index 716800c0cc..abf42b9a58 100644 --- a/e2e-tests/tests/cert-manager-tls/05-deploy-cert-manager.yaml +++ b/e2e-tests/tests/cert-manager-tls/05-deploy-cert-manager.yaml @@ -13,4 +13,4 @@ commands: kubectl -n "$NAMESPACE" delete pod -l postgres-operator.crunchydata.com/role=pgbouncer,postgres-operator.crunchydata.com/cluster=cert-manager-tls wait_cluster_consistency cert-manager-tls - timeout: 120 + timeout: 240 diff --git a/e2e-tests/tests/cert-manager-tls/24-verify-external-cluster-issuer.yaml b/e2e-tests/tests/cert-manager-tls/24-verify-external-cluster-issuer.yaml new file mode 100644 index 0000000000..5d91ebb26f --- /dev/null +++ b/e2e-tests/tests/cert-manager-tls/24-verify-external-cluster-issuer.yaml @@ -0,0 +1,88 @@ +apiVersion: kuttl.dev/v1beta1 +kind: TestStep +commands: + - script: |- + set -o errexit + set -o xtrace + + source ../../functions + + # ClusterIssuers are cluster-scoped, so the name is qualified by the + # test's namespace to avoid collisions with other test runs sharing + # the same Kubernetes cluster. + # + # This ClusterIssuer is created here by the test itself, the way a + # cluster admin managing their own PKI would - NOT by the operator. + # It exercises the "external" issuer mode, where the operator only + # references a pre-existing ClusterIssuer it does not own or manage. + issuer_name="${NAMESPACE}-shared-issuer" + bootstrap_issuer_name="${issuer_name}-bootstrap" + ca_cert_name="${issuer_name}-ca-cert" + + kubectl apply -f - </dev/null | openssl x509 -noout -subject -issuer -dates") + + echo "PostgreSQL certificate data: $pg_certificate_data" + + if [[ "$pg_certificate_data" != *"subject=CN=cert-manager-tls-postgres"* ]]; then + echo "PostgreSQL certificate CN does not match expected value" + echo "Expected subject to contain: CN=cert-manager-tls-postgres" + exit 1 + fi + + if [[ "$pg_certificate_data" != *"issuer=CN=cert-manager-tls-ca"* ]]; then + echo "PostgreSQL certificate issuer does not match expected value" + echo "Expected issuer to contain: CN=cert-manager-tls-ca" + exit 1 + fi + + ssl_info=$(run_psql_local "SHOW ssl;" "postgres:$(get_psql_user_pass cert-manager-tls-pguser-postgres)@$(get_psql_user_host cert-manager-tls-pguser-postgres)") + echo "SSL status: $ssl_info" + + if [[ "$ssl_info" != *"on"* ]]; then + echo "SSL is not enabled on PostgreSQL after switching to the external ClusterIssuer" + exit 1 + fi + timeout: 30 diff --git a/e2e-tests/tests/cert-manager-tls/26-verify-tls-pgbouncer-cluster-issuer.yaml b/e2e-tests/tests/cert-manager-tls/26-verify-tls-pgbouncer-cluster-issuer.yaml new file mode 100644 index 0000000000..4a7871d993 --- /dev/null +++ b/e2e-tests/tests/cert-manager-tls/26-verify-tls-pgbouncer-cluster-issuer.yaml @@ -0,0 +1,31 @@ +apiVersion: kuttl.dev/v1beta1 +kind: TestStep +commands: + - script: |- + set -o errexit + set -o xtrace + + source ../../functions + + pgb_certificate_data=$(run_comand_on_pod "openssl s_client -connect cert-manager-tls-pgbouncer:5432 -starttls postgres <<< '' 2>/dev/null | openssl x509 -noout -subject -issuer") + + echo "PgBouncer certificate data: $pgb_certificate_data" + + if [[ -z "$pgb_certificate_data" ]]; then + echo "Failed to retrieve PgBouncer TLS certificate" + exit 1 + fi + + if [[ "$pgb_certificate_data" != *"issuer=CN=cert-manager-tls-ca"* ]]; then + echo "Unexpected PgBouncer certificate issuer. Expected CN=cert-manager-tls-ca" + echo "Got: $pgb_certificate_data" + exit 1 + fi + + pgb_ssl=$(run_psql_local "SELECT ssl FROM pg_stat_ssl WHERE pid = pg_backend_pid();" "cert-manager-tls:$(get_psql_user_pass cert-manager-tls-pguser-cert-manager-tls)@cert-manager-tls-pgbouncer/postgres") + + if [[ "$pgb_ssl" != *"t"* ]]; then + echo "PgBouncer-to-PostgreSQL connection is not using SSL after switching to the external ClusterIssuer" + exit 1 + fi + timeout: 30 diff --git a/e2e-tests/tests/cert-manager-tls/27-verify-tls-replication-cluster-issuer.yaml b/e2e-tests/tests/cert-manager-tls/27-verify-tls-replication-cluster-issuer.yaml new file mode 100644 index 0000000000..bdea0ceb5f --- /dev/null +++ b/e2e-tests/tests/cert-manager-tls/27-verify-tls-replication-cluster-issuer.yaml @@ -0,0 +1,19 @@ +apiVersion: kuttl.dev/v1beta1 +kind: TestStep +commands: + - script: |- + set -o errexit + set -o xtrace + + source ../../functions + + repl_ssl_count=$(run_psql_local \ + "SELECT count(*) FROM pg_stat_ssl s JOIN pg_stat_replication r ON s.pid = r.pid WHERE s.ssl = true;" \ + "postgres:$(get_psql_user_pass cert-manager-tls-pguser-postgres)@cert-manager-tls-primary") + repl_ssl_count=$(echo "$repl_ssl_count" | tr -d '[:space:]') + + if [[ "$repl_ssl_count" -lt 1 ]]; then + echo "No SSL replication connections found after switching to the external ClusterIssuer, got: $repl_ssl_count" + exit 1 + fi + timeout: 30 diff --git a/e2e-tests/tests/cert-manager-tls/28-verify-tls-pgbackrest-cluster-issuer.yaml b/e2e-tests/tests/cert-manager-tls/28-verify-tls-pgbackrest-cluster-issuer.yaml new file mode 100644 index 0000000000..43db32b66a --- /dev/null +++ b/e2e-tests/tests/cert-manager-tls/28-verify-tls-pgbackrest-cluster-issuer.yaml @@ -0,0 +1,36 @@ +apiVersion: kuttl.dev/v1beta1 +kind: TestStep +commands: + - script: |- + set -o errexit + set -o xtrace + + source ../../functions + + instance=$(kubectl -n "$NAMESPACE" get pod \ + -l postgres-operator.crunchydata.com/cluster=cert-manager-tls,postgres-operator.crunchydata.com/role=primary \ + -o jsonpath='{.items[0].metadata.name}') + + kubectl -n "$NAMESPACE" exec "$instance" -c pgbackrest -- \ + test -f /etc/pgbackrest/server/server-tls.crt + kubectl -n "$NAMESPACE" exec "$instance" -c pgbackrest -- \ + test -f /etc/pgbackrest/server/server-tls.key + kubectl -n "$NAMESPACE" exec "$instance" -c pgbackrest -- \ + test -f /etc/pgbackrest/conf.d/~postgres-operator/tls-ca.crt + + pgbr_certificate_data=$(run_comand_on_pod "openssl s_client -connect ${instance}.cert-manager-tls-pods:8432 <<< '' 2>/dev/null | openssl x509 -noout -subject -issuer") + + if [[ -z "$pgbr_certificate_data" ]]; then + echo "Failed to retrieve pgBackRest TLS certificate" + exit 1 + fi + + if [[ "$pgbr_certificate_data" != *"issuer=CN=cert-manager-tls-ca"* ]]; then + echo "Unexpected pgBackRest certificate issuer. Expected CN=cert-manager-tls-ca" + echo "Got: $pgbr_certificate_data" + exit 1 + fi + + # Verify pgBackRest works over TLS, not just cert file existence + kubectl -n "$NAMESPACE" exec "$instance" -c pgbackrest -- pgbackrest info + timeout: 30 diff --git a/e2e-tests/tests/cert-manager-tls/99-remove-cluster-gracefully.yaml b/e2e-tests/tests/cert-manager-tls/99-remove-cluster-gracefully.yaml index dbc10adc6d..be599cf359 100644 --- a/e2e-tests/tests/cert-manager-tls/99-remove-cluster-gracefully.yaml +++ b/e2e-tests/tests/cert-manager-tls/99-remove-cluster-gracefully.yaml @@ -19,5 +19,14 @@ commands: remove_all_finalizers check_operator_panic destroy_operator + + # Cluster-scoped (or in cert-manager's namespace), so not owned by + # (and not garbage-collected with) the namespaced + # PostgresCluster/PerconaPGCluster deleted above. + issuer_name="${NAMESPACE}-shared-issuer" + kubectl delete clusterissuer "$issuer_name" "${issuer_name}-bootstrap" --ignore-not-found=true + kubectl -n cert-manager delete certificate "${issuer_name}-ca-cert" --ignore-not-found=true + kubectl -n cert-manager delete secret "${issuer_name}-ca-cert" --ignore-not-found=true + destroy_cert_manager timeout: 60 \ No newline at end of file diff --git a/internal/controller/postgrescluster/controller.go b/internal/controller/postgrescluster/controller.go index dbcf5f88e9..35d6731aeb 100644 --- a/internal/controller/postgrescluster/controller.go +++ b/internal/controller/postgrescluster/controller.go @@ -300,7 +300,7 @@ func (r *Reconciler) Reconcile( rootCA, err = r.reconcileRootCertificate(ctx, cluster) } - if err == nil && rootCA != nil { + if err == nil { certManagerManaged, certErr := r.isRootCACertManagerManaged(ctx, cluster) if certErr != nil { log.V(1).Info("failed to check if root CA is cert-manager managed, will retry on next reconcile", diff --git a/internal/controller/postgrescluster/instance.go b/internal/controller/postgrescluster/instance.go index ac3a280a19..3e849c3f39 100644 --- a/internal/controller/postgrescluster/instance.go +++ b/internal/controller/postgrescluster/instance.go @@ -1586,15 +1586,20 @@ func (r *Reconciler) reconcileCertManagerInstanceCertificates( _ = leafCert.Certificate.UnmarshalText(instanceCerts.Data["dns.crt"]) _ = leafCert.PrivateKey.UnmarshalText(instanceCerts.Data["dns.key"]) + caCert, err := instanceCACert(rootCertificateAuth, existing) + if err != nil { + return nil, err + } + err = patroni.InstanceCertificates(ctx, - rootCertificateAuth.Certificate, leafCert.Certificate, + caCert, leafCert.Certificate, leafCert.PrivateKey, instanceCerts) if err != nil { return nil, errors.Wrap(err, "failed to add patroni certificates") } err = pgbackrest.InstanceCertificates(ctx, cluster, - rootCertificateAuth.Certificate, leafCert.Certificate, leafCert.PrivateKey, + caCert, leafCert.Certificate, leafCert.PrivateKey, instanceCerts) if err != nil { return nil, errors.Wrap(err, "failed to add pgbackrest certificates") @@ -1608,6 +1613,23 @@ func (r *Reconciler) reconcileCertManagerInstanceCertificates( return instanceCerts, nil } +func instanceCACert(rootCertificateAuth *pki.RootCertificateAuthority, issuedSecret *corev1.Secret) (pki.Certificate, error) { + if rootCertificateAuth != nil { + return rootCertificateAuth.Certificate, nil + } + + ca := issuedSecret.Data[corev1.ServiceAccountRootCAKey] + if len(ca) == 0 { + return pki.Certificate{}, errors.New("external issuer did not return a CA certificate for the instance") + } + + var caCert pki.Certificate + if err := caCert.UnmarshalText(ca); err != nil { + return pki.Certificate{}, errors.Wrap(err, "failed to parse CA certificate from cert-manager secret") + } + return caCert, nil +} + // reconcileInternalInstanceCertificates creates instance certificates using internal PKI. func (r *Reconciler) reconcileInternalInstanceCertificates( ctx context.Context, cluster *v1beta1.PostgresCluster, diff --git a/internal/controller/postgrescluster/instance_test.go b/internal/controller/postgrescluster/instance_test.go index 868457a709..fe4e1ca889 100644 --- a/internal/controller/postgrescluster/instance_test.go +++ b/internal/controller/postgrescluster/instance_test.go @@ -37,6 +37,7 @@ import ( "github.com/percona/percona-postgresql-operator/v2/internal/controller/runtime" "github.com/percona/percona-postgresql-operator/v2/internal/logging" "github.com/percona/percona-postgresql-operator/v2/internal/naming" + "github.com/percona/percona-postgresql-operator/v2/internal/pki" "github.com/percona/percona-postgresql-operator/v2/internal/testing/cmp" "github.com/percona/percona-postgresql-operator/v2/internal/testing/events" "github.com/percona/percona-postgresql-operator/v2/internal/testing/require" @@ -2158,3 +2159,45 @@ func TestCleanupDisruptionBudgets(t *testing.T) { }) }) } + +func TestInstanceCACert(t *testing.T) { + t.Run("uses rootCertificateAuth when present", func(t *testing.T) { + root, err := pki.NewRootCertificateAuthority() + assert.NilError(t, err) + + caCert, err := instanceCACert(root, &corev1.Secret{}) + assert.NilError(t, err) + + want, err := root.Certificate.MarshalText() + assert.NilError(t, err) + got, err := caCert.MarshalText() + assert.NilError(t, err) + assert.DeepEqual(t, want, got) + }) + + t.Run("parses ca.crt from issued secret when rootCertificateAuth is nil", func(t *testing.T) { + root, err := pki.NewRootCertificateAuthority() + assert.NilError(t, err) + caBytes, err := root.Certificate.MarshalText() + assert.NilError(t, err) + + issuedSecret := &corev1.Secret{Data: map[string][]byte{corev1.ServiceAccountRootCAKey: caBytes}} + + caCert, err := instanceCACert(nil, issuedSecret) + assert.NilError(t, err) + got, err := caCert.MarshalText() + assert.NilError(t, err) + assert.DeepEqual(t, caBytes, got) + }) + + t.Run("errors when issued secret has no ca.crt", func(t *testing.T) { + _, err := instanceCACert(nil, &corev1.Secret{}) + assert.ErrorContains(t, err, "did not return a CA certificate") + }) + + t.Run("errors when ca.crt is not a valid certificate", func(t *testing.T) { + issuedSecret := &corev1.Secret{Data: map[string][]byte{corev1.ServiceAccountRootCAKey: []byte("not a cert")}} + _, err := instanceCACert(nil, issuedSecret) + assert.ErrorContains(t, err, "failed to parse CA certificate") + }) +} diff --git a/internal/controller/postgrescluster/pgbackrest.go b/internal/controller/postgrescluster/pgbackrest.go index 4a51d94709..09c9a5fc1c 100644 --- a/internal/controller/postgrescluster/pgbackrest.go +++ b/internal/controller/postgrescluster/pgbackrest.go @@ -2322,9 +2322,9 @@ func (r *Reconciler) reconcileCertManagerPGBackRestSecret( // Populate the pgBackRest secret from cert-manager-issued certs. initialize.Map(&intent.Data) - caCert, err := rootCA.Certificate.MarshalText() + caCert, err := pgBackRestCACert(rootCA, clientSecret, repoSecret) if err != nil { - return errors.Wrap(err, "failed to marshal root CA certificate") + return err } intent.Data[pgbackrest.CertAuthoritySecretKey] = caCert intent.Data[pgbackrest.CertClientSecretKey] = clientSecret.Data[corev1.TLSCertKey] @@ -2335,6 +2335,22 @@ func (r *Reconciler) reconcileCertManagerPGBackRestSecret( return nil } +func pgBackRestCACert(rootCA *pki.RootCertificateAuthority, clientSecret, repoSecret *corev1.Secret) ([]byte, error) { + if rootCA != nil { + caCert, err := rootCA.Certificate.MarshalText() + return caCert, errors.Wrap(err, "failed to marshal root CA certificate") + } + + if ca := clientSecret.Data[corev1.ServiceAccountRootCAKey]; len(ca) > 0 { + return ca, nil + } + if ca := repoSecret.Data[corev1.ServiceAccountRootCAKey]; len(ca) > 0 { + return ca, nil + } + + return nil, errors.New("external issuer did not return a CA certificate for pgBackRest") +} + // +kubebuilder:rbac:groups="",resources="serviceaccounts",verbs={create,patch} // +kubebuilder:rbac:groups="rbac.authorization.k8s.io",resources="roles",verbs={create,patch} // +kubebuilder:rbac:groups="rbac.authorization.k8s.io",resources="rolebindings",verbs={create,patch} diff --git a/internal/controller/postgrescluster/pgbackrest_test.go b/internal/controller/postgrescluster/pgbackrest_test.go index e41a11e83e..363c29a5b2 100644 --- a/internal/controller/postgrescluster/pgbackrest_test.go +++ b/internal/controller/postgrescluster/pgbackrest_test.go @@ -4571,3 +4571,40 @@ func TestBackupsEnabled(t *testing.T) { assert.Assert(t, backupsReconciliationAllowed) }) } + +func TestPgBackRestCACert(t *testing.T) { + t.Run("uses rootCA when present", func(t *testing.T) { + root, err := pki.NewRootCertificateAuthority() + assert.NilError(t, err) + + caCert, err := pgBackRestCACert(root, &corev1.Secret{}, &corev1.Secret{}) + assert.NilError(t, err) + + want, err := root.Certificate.MarshalText() + assert.NilError(t, err) + assert.DeepEqual(t, want, caCert) + }) + + t.Run("falls back to client secret ca.crt when rootCA is nil", func(t *testing.T) { + clientSecret := &corev1.Secret{Data: map[string][]byte{corev1.ServiceAccountRootCAKey: []byte("client-ca-bytes")}} + repoSecret := &corev1.Secret{} + + caCert, err := pgBackRestCACert(nil, clientSecret, repoSecret) + assert.NilError(t, err) + assert.DeepEqual(t, []byte("client-ca-bytes"), caCert) + }) + + t.Run("falls back to repo secret ca.crt when client secret has none", func(t *testing.T) { + clientSecret := &corev1.Secret{} + repoSecret := &corev1.Secret{Data: map[string][]byte{corev1.ServiceAccountRootCAKey: []byte("repo-ca-bytes")}} + + caCert, err := pgBackRestCACert(nil, clientSecret, repoSecret) + assert.NilError(t, err) + assert.DeepEqual(t, []byte("repo-ca-bytes"), caCert) + }) + + t.Run("errors when neither secret has a CA cert", func(t *testing.T) { + _, err := pgBackRestCACert(nil, &corev1.Secret{}, &corev1.Secret{}) + assert.ErrorContains(t, err, "did not return a CA certificate") + }) +} diff --git a/internal/controller/postgrescluster/pki.go b/internal/controller/postgrescluster/pki.go index 7ee3d61f7f..36f79fd627 100644 --- a/internal/controller/postgrescluster/pki.go +++ b/internal/controller/postgrescluster/pki.go @@ -42,12 +42,23 @@ func (r *Reconciler) reconcileRootCertificate( ) ( *pki.RootCertificateAuthority, error, ) { + mode, err := certmanager.ResolveIssuerMode(ctx, r.Client, cluster) + if err != nil { + return nil, errors.Wrap(err, "failed to resolve issuer mode") + } + if mode == certmanager.IssuerModeExternal { + return nil, nil + } + const keyCertificate, keyPrivateKey = "root.crt", "root.key" // K8SPG-553 existing := &corev1.Secret{ ObjectMeta: naming.PostgresRootCASecret(cluster), } + if mode == certmanager.IssuerModeManagedCluster { + existing.ObjectMeta = naming.ClusterCACertSecret(cluster, certmanager.CertManagerNamespace()) + } privateKey := keyPrivateKey certificateKey := keyCertificate @@ -64,19 +75,8 @@ func (r *Reconciler) reconcileRootCertificate( } } - err := errors.WithStack( + err = errors.WithStack( r.Client.Get(ctx, client.ObjectKeyFromObject(existing), existing)) - // K8SPG-555: we need to check ca certificate from old operator versions - // TODO: remove when 2.4.0 will become unsupported - if k8serrors.IsNotFound(err) { - nn := client.ObjectKeyFromObject(existing) - nn.Name = naming.RootCertSecret - err = errors.WithStack( - r.Client.Get(ctx, nn, existing)) - if err == nil { - existing.Name = naming.RootCertSecret - } - } if k8serrors.IsNotFound(err) { err = nil @@ -104,6 +104,12 @@ func (r *Reconciler) reconcileRootCertificate( return nil, errors.New("waiting for cert-manager to issue a valid CA certificate") } + if mode == certmanager.IssuerModeManagedCluster { + // The cluster-scoped CA cert/secret is entirely cert-manager's + // responsibility; there is no internal-PKI fallback for it. + return nil, errors.New("waiting for cert-manager to issue a valid CA certificate") + } + root := &pki.RootCertificateAuthority{} if err == nil { @@ -186,8 +192,17 @@ func (r *Reconciler) reconcileCertManagerRootCertificate( return nil, errors.Wrap(err, "error applying CA certificate") } + mode, err := certmanager.ResolveIssuerMode(ctx, r.Client, cluster) + if err != nil { + return nil, errors.Wrap(err, "failed to resolve issuer mode") + } + secretMeta := naming.PostgresRootCASecret(cluster) + if mode == certmanager.IssuerModeManagedCluster { + secretMeta = naming.ClusterCACertSecret(cluster, certmanager.CertManagerNamespace()) + } + // Try to fetch the CA secret created by cert-manager. - secret := &corev1.Secret{ObjectMeta: naming.PostgresRootCASecret(cluster)} + secret := &corev1.Secret{ObjectMeta: secretMeta} if err := r.Client.Get(ctx, client.ObjectKeyFromObject(secret), secret); err != nil { if k8serrors.IsNotFound(err) { log.Info("waiting for cert-manager to issue CA certificate") @@ -226,14 +241,14 @@ func (r *Reconciler) reconcileClusterCertificate( } if certManagerManaged { - return r.reconcileCertManagerClusterCertificate(ctx, root, cluster, primaryService, replicaService) + return r.reconcileCertManagerClusterCertificate(ctx, cluster, primaryService, replicaService) } // cluster certificates are not managed by cert-manager // but Certificate object exists due to the bug described in K8SPG-1017 // we need to reconcile them anyway to update ownerRef for K8SPG-1007. if cert := certmanager.ClusterCertificateName(cluster); r.shouldReconcileCertManagerCertificate(ctx, cluster.Namespace, cert) { - _, err := r.reconcileCertManagerClusterCertificate(ctx, root, cluster, primaryService, replicaService) + _, err := r.reconcileCertManagerClusterCertificate(ctx, cluster, primaryService, replicaService) if err != nil { logging.FromContext(ctx).Error(err, "failed to reconcile Certificate", "name", cert) } @@ -327,17 +342,23 @@ func (r *Reconciler) reconcileInternalClusterCertificate( // reconcileCertManagerClusterCertificate creates a cluster certificate using cert-manager. // It first ensures the TLS issuer exists, then creates the cluster Certificate CR. func (r *Reconciler) reconcileCertManagerClusterCertificate( - ctx context.Context, root *pki.RootCertificateAuthority, - cluster *v1beta1.PostgresCluster, primaryService *corev1.Service, + ctx context.Context, + cluster *v1beta1.PostgresCluster, + primaryService *corev1.Service, replicaService *corev1.Service, ) ( *corev1.SecretProjection, error, ) { c := r.CertManagerCtrlFunc(r.Client, r.Scheme, false) - err := c.ApplyIssuer(ctx, cluster) + mode, err := certmanager.ResolveIssuerMode(ctx, r.Client, cluster) if err != nil { - return nil, errors.Wrap(err, "failed to apply TLS issuer") + return nil, errors.Wrap(err, "failed to resolve issuer mode") + } + if mode != certmanager.IssuerModeExternal { + if err := c.ApplyIssuer(ctx, cluster); err != nil { + return nil, errors.Wrap(err, "failed to apply TLS issuer") + } } primaryDNSNames, err := naming.ServiceDNSNames(ctx, primaryService, cluster.Spec.ClusterServiceDNSSuffix) @@ -385,11 +406,27 @@ func (r *Reconciler) isRootCACertManagerManaged(ctx context.Context, cluster *v1 return false, nil } + mode, err := certmanager.ResolveIssuerMode(ctx, r.Client, cluster) + if err != nil { + return false, errors.Wrap(err, "failed to resolve issuer mode") + } + installed, err := r.isCertManagerInstalled(ctx, cluster.Namespace) - if err != nil || !installed { + if err != nil { return false, err } + if mode != certmanager.IssuerModeManagedNamespaced { + if !installed { + return false, errors.New("cert-manager is required when spec.tls.issuerConf is set") + } + return true, nil + } + + if !installed { + return false, nil + } + rootSecret := &corev1.Secret{ObjectMeta: naming.PostgresRootCASecret(cluster)} err = r.Client.Get(ctx, client.ObjectKeyFromObject(rootSecret), rootSecret) if err != nil { diff --git a/internal/controller/postgrescluster/pki_test.go b/internal/controller/postgrescluster/pki_test.go index 2c61ad1d54..1b9e4d7a7b 100644 --- a/internal/controller/postgrescluster/pki_test.go +++ b/internal/controller/postgrescluster/pki_test.go @@ -12,6 +12,8 @@ import ( "strings" "testing" + cmv1 "github.com/cert-manager/cert-manager/pkg/apis/certmanager/v1" + cmmeta "github.com/cert-manager/cert-manager/pkg/apis/meta/v1" "github.com/pkg/errors" "gotest.tools/v3/assert" appsv1 "k8s.io/api/apps/v1" @@ -21,6 +23,7 @@ import ( "k8s.io/apimachinery/pkg/types" "k8s.io/client-go/rest" "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" "github.com/percona/percona-postgresql-operator/v2/internal/naming" "github.com/percona/percona-postgresql-operator/v2/internal/pki" @@ -397,30 +400,39 @@ func (m *mockCertManagerController) Check(context.Context, *rest.Config, string) func (m *mockCertManagerController) CertificateExists(context.Context, string, string) (bool, error) { return false, nil } + func (m *mockCertManagerController) ApplyIssuer(context.Context, *v1beta1.PostgresCluster) error { panic("unexpected call") } + func (m *mockCertManagerController) ApplyCAIssuer(context.Context, *v1beta1.PostgresCluster) error { panic("unexpected call") } + func (m *mockCertManagerController) ApplyCACertificate(context.Context, *v1beta1.PostgresCluster) error { panic("unexpected call") } + func (m *mockCertManagerController) ApplyClusterCertificate(context.Context, *v1beta1.PostgresCluster, []string) error { panic("unexpected call") } + func (m *mockCertManagerController) ApplyInstanceCertificate(context.Context, *v1beta1.PostgresCluster, string, []string) error { panic("unexpected call") } + func (m *mockCertManagerController) ApplyPGBouncerCertificate(context.Context, *v1beta1.PostgresCluster, []string) error { panic("unexpected call") } + func (m *mockCertManagerController) ApplyReplicationCertificate(context.Context, *v1beta1.PostgresCluster) error { panic("unexpected call") } + func (m *mockCertManagerController) ApplyPGBackRestClientCertificate(context.Context, *v1beta1.PostgresCluster) error { panic("unexpected call") } + func (m *mockCertManagerController) ApplyPGBackRestRepoCertificate(context.Context, *v1beta1.PostgresCluster, []string) error { panic("unexpected call") } @@ -445,39 +457,49 @@ type recoveryCertManagerController struct { func (m *recoveryCertManagerController) Check(context.Context, *rest.Config, string) error { return nil } + func (m *recoveryCertManagerController) CertificateExists(context.Context, string, string) (bool, error) { return true, nil } + func (m *recoveryCertManagerController) ApplyIssuer(context.Context, *v1beta1.PostgresCluster) error { m.applyIssuerCalls++ return nil } + func (m *recoveryCertManagerController) ApplyCAIssuer(context.Context, *v1beta1.PostgresCluster) error { return nil } + func (m *recoveryCertManagerController) ApplyCACertificate(context.Context, *v1beta1.PostgresCluster) error { return nil } + func (m *recoveryCertManagerController) ApplyClusterCertificate(context.Context, *v1beta1.PostgresCluster, []string) error { m.applyClusterCertificateCalls++ return nil } + func (m *recoveryCertManagerController) ApplyInstanceCertificate(context.Context, *v1beta1.PostgresCluster, string, []string) error { m.applyInstanceCertificateCalls++ return nil } + func (m *recoveryCertManagerController) ApplyPGBouncerCertificate(context.Context, *v1beta1.PostgresCluster, []string) error { m.applyPGBouncerCertificateCalls++ return nil } + func (m *recoveryCertManagerController) ApplyReplicationCertificate(context.Context, *v1beta1.PostgresCluster) error { m.applyReplicationCalls++ return nil } + func (m *recoveryCertManagerController) ApplyPGBackRestClientCertificate(context.Context, *v1beta1.PostgresCluster) error { m.applyPGBackRestClientCalls++ return nil } + func (m *recoveryCertManagerController) ApplyPGBackRestRepoCertificate(context.Context, *v1beta1.PostgresCluster, []string) error { return nil } @@ -787,3 +809,186 @@ func getCertFromSecret( fromSecret := &pki.Certificate{} return fromSecret, fromSecret.UnmarshalText(secretCRT) } + +// installedCertManagerController reports cert-manager as installed and lets +// every Apply* call succeed as a no-op — used for issuer-mode +// tests that only need isCertManagerInstalled to return true, not real +// Issuer/Certificate object creation (envtest has no cert-manager CRDs). +type installedCertManagerController struct{} + +func (installedCertManagerController) Check(context.Context, *rest.Config, string) error { + return nil +} + +func (installedCertManagerController) CertificateExists(context.Context, string, string) (bool, error) { + return false, nil +} + +func (installedCertManagerController) ApplyIssuer(context.Context, *v1beta1.PostgresCluster) error { + return nil +} + +func (installedCertManagerController) ApplyCAIssuer(context.Context, *v1beta1.PostgresCluster) error { + return nil +} + +func (installedCertManagerController) ApplyCACertificate(context.Context, *v1beta1.PostgresCluster) error { + return nil +} + +func (installedCertManagerController) ApplyClusterCertificate(context.Context, *v1beta1.PostgresCluster, []string) error { + return nil +} + +func (installedCertManagerController) ApplyInstanceCertificate(context.Context, *v1beta1.PostgresCluster, string, []string) error { + return nil +} + +func (installedCertManagerController) ApplyPGBouncerCertificate(context.Context, *v1beta1.PostgresCluster, []string) error { + return nil +} + +func (installedCertManagerController) ApplyReplicationCertificate(context.Context, *v1beta1.PostgresCluster) error { + return nil +} + +func (installedCertManagerController) ApplyPGBackRestClientCertificate(context.Context, *v1beta1.PostgresCluster) error { + return nil +} + +func (installedCertManagerController) ApplyPGBackRestRepoCertificate(context.Context, *v1beta1.PostgresCluster, []string) error { + return nil +} + +func installedCertManagerCtrlFunc(_ client.Client, _ *runtime.Scheme, _ bool) certmanager.Controller { + return installedCertManagerController{} +} + +func TestIssuerModeAwareness(t *testing.T) { + _, tClient := setupKubernetes(t) + require.ParallelCapacity(t, 1) + ctx := t.Context() + namespace := require.Namespace(t, tClient).Name + + t.Run("reconcileRootCertificate returns nil for external issuer", func(t *testing.T) { + cluster := testCluster() + cluster.Name = "external-root-cert" + cluster.Namespace = namespace + cluster.Spec.TLS = &v1beta1.TLSSpec{ + IssuerConf: &cmmeta.IssuerReference{Name: "vault-issuer", Kind: "VaultClusterIssuer"}, + } + assert.NilError(t, tClient.Create(ctx, cluster)) + + r := &Reconciler{ + Client: tClient, + Owner: ControllerName, + CertManagerCtrlFunc: certmanager.NewController, + } + + root, err := r.reconcileRootCertificate(ctx, cluster) + assert.NilError(t, err) + assert.Assert(t, root == nil) + }) + + t.Run("isRootCACertManagerManaged errors when cert-manager missing and issuerConf is set", func(t *testing.T) { + cluster := testCluster() + cluster.Name = "external-no-certmanager" + cluster.Namespace = namespace + cluster.Spec.TLS = &v1beta1.TLSSpec{ + IssuerConf: &cmmeta.IssuerReference{Name: "vault-issuer", Kind: "VaultClusterIssuer"}, + } + assert.NilError(t, tClient.Create(ctx, cluster)) + + r := &Reconciler{ + Client: tClient, + Owner: ControllerName, + CertManagerCtrlFunc: mockCertManagerCtrlFunc, + RestConfig: nil, // isCertManagerInstalled short-circuits to false + } + + _, err := r.isRootCACertManagerManaged(ctx, cluster) + assert.ErrorContains(t, err, "cert-manager is required") + }) + + t.Run("isRootCACertManagerManaged returns true immediately for external issuer when cert-manager installed", func(t *testing.T) { + cluster := testCluster() + cluster.Name = "external-with-certmanager" + cluster.Namespace = namespace + cluster.Spec.TLS = &v1beta1.TLSSpec{ + IssuerConf: &cmmeta.IssuerReference{Name: "vault-issuer", Kind: "VaultClusterIssuer"}, + } + assert.NilError(t, tClient.Create(ctx, cluster)) + + r := &Reconciler{ + Client: tClient, + Owner: ControllerName, + CertManagerCtrlFunc: installedCertManagerCtrlFunc, + RestConfig: &rest.Config{}, + } + + managed, err := r.isRootCACertManagerManaged(ctx, cluster) + assert.NilError(t, err) + assert.Assert(t, managed) + }) + + t.Run("isRootCACertManagerManaged returns true immediately for managed cluster issuer when cert-manager installed", func(t *testing.T) { + cluster := testCluster() + cluster.Name = "cluster-scoped-with-certmanager" + cluster.Namespace = namespace + cluster.Spec.TLS = &v1beta1.TLSSpec{ + IssuerConf: &cmmeta.IssuerReference{Name: "shared-tls-issuer", Kind: cmv1.ClusterIssuerKind}, + } + assert.NilError(t, tClient.Create(ctx, cluster)) + + // certmanager.ResolveIssuerMode issues a live Get for the named + // ClusterIssuer. The shared envtest scheme used by setupKubernetes(t) + // (internal/controller/runtime.Scheme) does not register cert-manager + // types, and envtest has no cert-manager CRDs installed either, so + // tClient can't serve that Get (pre-existing gap, out of scope for + // this task's file set). A scheme-complete fake client stands in for + // tClient as this Reconciler's Client so the ClusterIssuer Get + // resolves to NotFound (mode ManagedCluster), matching what happens + // against a real cluster where the operator can read the API but the + // ClusterIssuer doesn't exist yet. + certManagerScheme := runtime.NewScheme() + assert.NilError(t, cmv1.AddToScheme(certManagerScheme)) + fakeClient := fake.NewClientBuilder().WithScheme(certManagerScheme).Build() + + r := &Reconciler{ + Client: fakeClient, + Owner: ControllerName, + CertManagerCtrlFunc: installedCertManagerCtrlFunc, + RestConfig: &rest.Config{}, + } + + managed, err := r.isRootCACertManagerManaged(ctx, cluster) + assert.NilError(t, err) + assert.Assert(t, managed) + }) + + t.Run("reconcileCertManagerClusterCertificate skips ApplyIssuer for external issuer", func(t *testing.T) { + cluster := testCluster() + cluster.Name = "external-cluster-cert" + cluster.Namespace = namespace + cluster.Spec.TLS = &v1beta1.TLSSpec{ + IssuerConf: &cmmeta.IssuerReference{Name: "vault-issuer", Kind: "VaultClusterIssuer"}, + } + assert.NilError(t, tClient.Create(ctx, cluster)) + + recovery := &recoveryCertManagerController{} + r := &Reconciler{ + Client: tClient, + Owner: ControllerName, + CertManagerCtrlFunc: func(_ client.Client, _ *runtime.Scheme, _ bool) certmanager.Controller { + return recovery + }, + } + + primaryService := &corev1.Service{ObjectMeta: metav1.ObjectMeta{Namespace: namespace, Name: "external-cluster-cert-primary"}} + replicaService := &corev1.Service{ObjectMeta: metav1.ObjectMeta{Namespace: namespace, Name: "external-cluster-cert-replicas"}} + + _, err := r.reconcileCertManagerClusterCertificate(ctx, cluster, primaryService, replicaService) + assert.NilError(t, err) + assert.Equal(t, recovery.applyIssuerCalls, 0) + }) +} diff --git a/internal/naming/labels.go b/internal/naming/labels.go index ad67a6bc9c..ef1fe0d135 100644 --- a/internal/naming/labels.go +++ b/internal/naming/labels.go @@ -22,6 +22,10 @@ const ( LabelPerconaName = appK8sPrefix + "name" LabelPerconaInstance = appK8sPrefix + "instance" + // LabelPerconaManagedByValue is the value this operator stamps onto + // LabelPerconaManagedBy. + LabelPerconaManagedByValue = "percona-postgresql-operator" + // LabelCluster et al. provides the fundamental labels for Postgres instances LabelCluster = labelPrefix + "cluster" LabelInstance = labelPrefix + "instance" @@ -319,7 +323,7 @@ func WithPerconaLabels(set map[string]string, clusterName, component, crVersion } ls := labels.Set{ - LabelPerconaManagedBy: "percona-postgresql-operator", + LabelPerconaManagedBy: LabelPerconaManagedByValue, LabelPerconaName: "percona-postgresql", LabelPerconaPartOf: "percona-postgresql", } diff --git a/internal/naming/names.go b/internal/naming/names.go index cca81200ef..537a875967 100644 --- a/internal/naming/names.go +++ b/internal/naming/names.go @@ -320,7 +320,8 @@ func InstanceCertificates(instance metav1.Object) metav1.ObjectMeta { // InstanceSet returns the ObjectMeta necessary to lookup the objects // associated with a single instance set. Includes PodDisruptionBudgets func InstanceSet(cluster *v1beta1.PostgresCluster, - set *v1beta1.PostgresInstanceSetSpec) metav1.ObjectMeta { + set *v1beta1.PostgresInstanceSetSpec, +) metav1.ObjectMeta { return metav1.ObjectMeta{ Name: cluster.Name + "-set-" + set.Name, Namespace: cluster.Namespace, @@ -500,7 +501,8 @@ func PGBackRestRBAC(cluster *v1beta1.PostgresCluster) metav1.ObjectMeta { // PGBackRestRepoVolume returns the ObjectMeta for a pgBackRest repository volume func PGBackRestRepoVolume(cluster *v1beta1.PostgresCluster, - repoName string) metav1.ObjectMeta { + repoName string, +) metav1.ObjectMeta { return metav1.ObjectMeta{ Name: fmt.Sprintf("%s-%s", cluster.GetName(), repoName), Namespace: cluster.GetNamespace(), @@ -656,3 +658,18 @@ func TLSIssuer(cluster *v1beta1.PostgresCluster) metav1.ObjectMeta { Name: cluster.Name + "-tls-issuer", } } + +// ClusterCAIssuer returns the ObjectMeta for the cluster-scoped CA ClusterIssuer used by cert-manager. +func ClusterCAIssuer(cluster *v1beta1.PostgresCluster) metav1.ObjectMeta { + return metav1.ObjectMeta{ + Name: cluster.Spec.TLS.IssuerConf.Name + "-ca-issuer", + } +} + +// ClusterCACertSecret returns the ObjectMeta for the cluster-scoped CA Secret in cert-manager's namespace. +func ClusterCACertSecret(cluster *v1beta1.PostgresCluster, certManagerNamespace string) metav1.ObjectMeta { + return metav1.ObjectMeta{ + Namespace: certManagerNamespace, + Name: cluster.Spec.TLS.IssuerConf.Name + "-ca-cert", + } +} diff --git a/internal/naming/names_test.go b/internal/naming/names_test.go index 86d3d736be..0834d72d1f 100644 --- a/internal/naming/names_test.go +++ b/internal/naming/names_test.go @@ -8,6 +8,7 @@ import ( "strings" "testing" + cmmeta "github.com/cert-manager/cert-manager/pkg/apis/meta/v1" "gotest.tools/v3/assert" appsv1 "k8s.io/api/apps/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -332,3 +333,29 @@ func TestPortNamesUniqueAndValid(t *testing.T) { names.Insert(name) } } + +func TestClusterCAIssuer(t *testing.T) { + cluster := &v1beta1.PostgresCluster{} + cluster.Namespace = "postgres-operator" + cluster.Name = "hippo" + cluster.Spec.TLS = &v1beta1.TLSSpec{ + IssuerConf: &cmmeta.IssuerReference{Name: "shared-tls-issuer"}, + } + + meta := ClusterCAIssuer(cluster) + assert.Equal(t, meta.Name, "shared-tls-issuer-ca-issuer") + assert.Equal(t, meta.Namespace, "") +} + +func TestClusterCACertSecret(t *testing.T) { + cluster := &v1beta1.PostgresCluster{} + cluster.Namespace = "postgres-operator" + cluster.Name = "hippo" + cluster.Spec.TLS = &v1beta1.TLSSpec{ + IssuerConf: &cmmeta.IssuerReference{Name: "shared-tls-issuer"}, + } + + meta := ClusterCACertSecret(cluster, "cert-manager") + assert.Equal(t, meta.Name, "shared-tls-issuer-ca-cert") + assert.Equal(t, meta.Namespace, "cert-manager") +} diff --git a/internal/pgbouncer/reconcile.go b/internal/pgbouncer/reconcile.go index f7dc41c07d..83f5c01ff6 100644 --- a/internal/pgbouncer/reconcile.go +++ b/internal/pgbouncer/reconcile.go @@ -86,12 +86,14 @@ func Secret(ctx context.Context, if inCluster.Spec.Proxy.PGBouncer.CustomTLSSecret == nil { if frontendCertManagerSecret != nil { if err == nil { - outSecret.Data[certFrontendAuthoritySecretKey], err = inRoot.Certificate.MarshalText() + outSecret.Data[certFrontendAuthoritySecretKey], err = frontendAuthorityCert(inRoot, frontendCertManagerSecret) } if err == nil { outSecret.Data[certFrontendSecretKey] = frontendCertManagerSecret.Data[corev1.TLSCertKey] outSecret.Data[certFrontendPrivateKeySecretKey] = frontendCertManagerSecret.Data[corev1.TLSPrivateKeyKey] } + } else if inRoot == nil { + err = errors.New("waiting for cert-manager to issue pgbouncer frontend certificate") } else { leaf := &pki.LeafCertificate{} var dnsNames []string @@ -146,6 +148,20 @@ func Secret(ctx context.Context, return err } +// frontendAuthorityCert returns the CA certificate bytes to trust for the +// PgBouncer frontend certificate. It prefers the internal PKI root, which is +// nil when an external cert-manager issuer is in use; in that case, it falls +// back to the "ca.crt" that cert-manager writes into the frontend Secret. +func frontendAuthorityCert(inRoot *pki.RootCertificateAuthority, frontendCertManagerSecret *corev1.Secret) ([]byte, error) { + if inRoot != nil { + return inRoot.Certificate.MarshalText() + } + if ca := frontendCertManagerSecret.Data[tlsAuthoritySecretKey]; len(ca) > 0 { + return ca, nil + } + return nil, errors.New("external issuer did not return a CA certificate for pgbouncer frontend") +} + // Pod populates a PodSpec with the container and volumes needed to run PgBouncer. func Pod( ctx context.Context, diff --git a/percona/certmanager/certmanager.go b/percona/certmanager/certmanager.go index f76c951f7e..c3cbf7854f 100644 --- a/percona/certmanager/certmanager.go +++ b/percona/certmanager/certmanager.go @@ -2,9 +2,11 @@ package certmanager import ( "context" + "os" "regexp" "time" + "github.com/cert-manager/cert-manager/pkg/apis/certmanager" v1 "github.com/cert-manager/cert-manager/pkg/apis/certmanager/v1" cmmeta "github.com/cert-manager/cert-manager/pkg/apis/meta/v1" "github.com/cert-manager/cert-manager/pkg/util/cmapichecker" @@ -47,6 +49,87 @@ const ( DefaultRenewBefore = 30 * 24 * time.Hour ) +type IssuerMode int + +const ( + // IssuerModeManagedNamespaced: operator owns and manages a namespaced self-signed Issuer + IssuerModeManagedNamespaced IssuerMode = iota + // IssuerModeManagedCluster: operator owns and manages a cluster-scoped self-signed ClusterIssuer + IssuerModeManagedCluster + // IssuerModeExternal: operator does nothing for issuer, simply trusts that it exists and uses it to sign certificates + IssuerModeExternal +) + +// CertManagerNamespace returns the namespace where cert-manager is installed. +func CertManagerNamespace() string { + if ns := os.Getenv("CERTMANAGER_NAMESPACE"); ns != "" { + return ns + } + return "cert-manager" +} + +func issuerConf(cluster *v1beta1.PostgresCluster) *cmmeta.IssuerReference { + if cluster.Spec.TLS == nil { + return nil + } + return cluster.Spec.TLS.IssuerConf +} + +// ResolveIssuerMode determines how the operator should handle cluster.Spec.TLS.IssuerConf. +func ResolveIssuerMode(ctx context.Context, cl client.Client, cluster *v1beta1.PostgresCluster) (IssuerMode, error) { + ic := issuerConf(cluster) + if ic == nil { + return IssuerModeManagedNamespaced, nil + } + + switch ic.Kind { + case "", v1.IssuerKind: + return IssuerModeManagedNamespaced, nil + case v1.ClusterIssuerKind: + existing := &v1.ClusterIssuer{} + err := cl.Get(ctx, types.NamespacedName{Name: ic.Name}, existing) + switch { + // ClusterIssuer not found, operator will create it + case k8serrors.IsNotFound(err): + return IssuerModeManagedCluster, nil + case err == nil: + // ClusterIssuer found, check if the operator created it + if val, ok := existing.GetLabels()[naming.LabelPerconaManagedBy]; ok && val == naming.LabelPerconaManagedByValue { + return IssuerModeManagedCluster, nil + } + // Operator did not create it, it is managed externally + return IssuerModeExternal, nil + case k8serrors.IsForbidden(err): + // Operator does not have permission, trust blindly that it exists and managed externally + return IssuerModeExternal, nil + default: + return IssuerModeManagedNamespaced, errors.Wrap(err, "failed to get cluster issuer") + } + default: + return IssuerModeExternal, nil + } +} + +func issuerRef(cluster *v1beta1.PostgresCluster, mode IssuerMode) cmmeta.IssuerReference { + switch mode { + case IssuerModeExternal: + ic := issuerConf(cluster) + group := ic.Group + if group == "" { + group = certmanager.GroupName + } + return cmmeta.IssuerReference{Name: ic.Name, Kind: ic.Kind, Group: group} + case IssuerModeManagedCluster: + return cmmeta.IssuerReference{Name: issuerConf(cluster).Name, Kind: v1.ClusterIssuerKind, Group: certmanager.GroupName} + default: + name := naming.TLSIssuer(cluster).Name + if ic := issuerConf(cluster); ic != nil && ic.Name != "" { + name = ic.Name + } + return cmmeta.IssuerReference{Name: name, Kind: v1.IssuerKind, Group: certmanager.GroupName} + } +} + type controller struct { cl client.Client scheme *runtime.Scheme @@ -112,11 +195,57 @@ func (c *controller) CertificateExists(ctx context.Context, namespace, certName return false, errors.Wrapf(err, "get certificate/%s", certName) } +// ApplyIssuer creates the CA-backed Issuer resource that signs every leaf +// Certificate for the given PostgresCluster (or a cluster-scoped CA-backed +// ClusterIssuer when spec.tls.issuerConf.kind is "ClusterIssuer"). No-op when the resolved mode is external. func (c *controller) ApplyIssuer(ctx context.Context, cluster *v1beta1.PostgresCluster) error { + mode, err := ResolveIssuerMode(ctx, c.cl, cluster) + if err != nil { + return errors.Wrap(err, "failed to resolve issuer mode") + } + if mode == IssuerModeExternal { + return nil + } + + if mode == IssuerModeManagedCluster { + caSecretName := naming.ClusterCACertSecret(cluster, CertManagerNamespace()).Name + meta := metav1.ObjectMeta{ + Name: issuerRef(cluster, mode).Name, + Labels: map[string]string{ + naming.LabelPerconaManagedBy: naming.LabelPerconaManagedByValue, + }, + } + + existing := &v1.ClusterIssuer{} + err := c.cl.Get(ctx, types.NamespacedName{Name: meta.Name}, existing) + if err == nil { + return nil + } + if !k8serrors.IsNotFound(err) { + return errors.Wrap(err, "failed to get cluster issuer") + } + + issuer := &v1.ClusterIssuer{ + ObjectMeta: meta, + Spec: v1.IssuerSpec{ + IssuerConfig: v1.IssuerConfig{ + CA: &v1.CAIssuer{SecretName: caSecretName}, + }, + }, + } + if err := c.cl.Create(ctx, issuer); err != nil { + return errors.Wrap(err, "failed to create cluster issuer") + } + return nil + } + meta := naming.TLSIssuer(cluster) + if ic := issuerConf(cluster); ic != nil && ic.Name != "" { + meta.Name = ic.Name + } existing := &v1.Issuer{} - err := c.cl.Get(ctx, types.NamespacedName{Name: meta.Name, Namespace: meta.Namespace}, existing) + err = c.cl.Get(ctx, types.NamespacedName{Name: meta.Name, Namespace: meta.Namespace}, existing) if err == nil { hasOwnerRef, err := controllerutil.HasOwnerReference(existing.OwnerReferences, cluster, c.scheme) if err != nil { @@ -164,12 +293,51 @@ func (c *controller) ApplyIssuer(ctx context.Context, cluster *v1beta1.PostgresC return nil } -// ApplyCAIssuer creates a SelfSigned Issuer resource for the given PostgresCluster. +// ApplyCAIssuer creates a SelfSigned Issuer resource for the given +// PostgresCluster (or a cluster-scoped SelfSigned ClusterIssuer when +// spec.tls.issuerConf.kind is "ClusterIssuer"). No-op when the +// resolved mode is external. func (c *controller) ApplyCAIssuer(ctx context.Context, cluster *v1beta1.PostgresCluster) error { + mode, err := ResolveIssuerMode(ctx, c.cl, cluster) + if err != nil { + return errors.Wrap(err, "failed to resolve issuer mode") + } + if mode == IssuerModeExternal { + return nil + } + + spec := v1.IssuerSpec{ + IssuerConfig: v1.IssuerConfig{ + SelfSigned: &v1.SelfSignedIssuer{}, + }, + } + + if mode == IssuerModeManagedCluster { + meta := naming.ClusterCAIssuer(cluster) + meta.Labels = map[string]string{ + naming.LabelPerconaManagedBy: naming.LabelPerconaManagedByValue, + } + + existing := &v1.ClusterIssuer{} + err := c.cl.Get(ctx, types.NamespacedName{Name: meta.Name}, existing) + if err == nil { + return nil + } + if !k8serrors.IsNotFound(err) { + return errors.Wrap(err, "failed to get CA cluster issuer") + } + + issuer := &v1.ClusterIssuer{ObjectMeta: meta, Spec: spec} + if err := c.cl.Create(ctx, issuer); err != nil { + return errors.Wrap(err, "failed to create ca cluster issuer") + } + return nil + } + meta := naming.CAIssuer(cluster) existing := &v1.Issuer{} - err := c.cl.Get(ctx, types.NamespacedName{Name: meta.Name, Namespace: meta.Namespace}, existing) + err = c.cl.Get(ctx, types.NamespacedName{Name: meta.Name, Namespace: meta.Namespace}, existing) if err == nil { hasOwnerRef, err := controllerutil.HasOwnerReference(existing.OwnerReferences, cluster, c.scheme) if err != nil { @@ -195,14 +363,7 @@ func (c *controller) ApplyCAIssuer(ctx context.Context, cluster *v1beta1.Postgre return errors.Wrap(err, "failed to get CA issuer") } - issuer := &v1.Issuer{ - ObjectMeta: meta, - Spec: v1.IssuerSpec{ - IssuerConfig: v1.IssuerConfig{ - SelfSigned: &v1.SelfSignedIssuer{}, - }, - }, - } + issuer := &v1.Issuer{ObjectMeta: meta, Spec: spec} if err := controllerutil.SetControllerReference(cluster, issuer, c.scheme); err != nil { return errors.Wrap(err, "failed to set controller reference") @@ -215,35 +376,60 @@ func (c *controller) ApplyCAIssuer(ctx context.Context, cluster *v1beta1.Postgre return nil } +// ApplyCACertificate creates the self-signed CA Certificate for the given +// PostgresCluster. For IssuerModeManagedCluster, it's placed in +// cert-manager's shared namespace under a cluster-qualified name and gets no +// owner reference (it may be shared by other PostgresClusters). No-op for +// IssuerModeExternal. func (c *controller) ApplyCACertificate(ctx context.Context, cluster *v1beta1.PostgresCluster) error { - certName := naming.PostgresRootCASecret(cluster).Name + mode, err := ResolveIssuerMode(ctx, c.cl, cluster) + if err != nil { + return errors.Wrap(err, "failed to resolve issuer mode") + } + if mode == IssuerModeExternal { + return nil + } caDuration := DefaultCertDuration if cluster.Spec.TLS != nil && cluster.Spec.TLS.CAValidityDuration != nil { caDuration = cluster.Spec.TLS.CAValidityDuration.Duration } + clusterScoped := mode == IssuerModeManagedCluster + + secretMeta := naming.PostgresRootCASecret(cluster) + issuerRefValue := cmmeta.IssuerReference{Name: naming.CAIssuer(cluster).Name, Kind: v1.IssuerKind} + + if clusterScoped { + secretMeta = naming.ClusterCACertSecret(cluster, CertManagerNamespace()) + issuerRefValue = cmmeta.IssuerReference{Name: naming.ClusterCAIssuer(cluster).Name, Kind: v1.ClusterIssuerKind} + } + certName := secretMeta.Name + certNamespace := secretMeta.Namespace + existing := &v1.Certificate{} - err := c.cl.Get(ctx, types.NamespacedName{Name: certName, Namespace: cluster.Namespace}, existing) + err = c.cl.Get(ctx, types.NamespacedName{Name: certName, Namespace: certNamespace}, existing) if err == nil { needsUpdate := false - hasOwnerRef, err := controllerutil.HasOwnerReference(existing.OwnerReferences, cluster, c.scheme) - if err != nil { - return errors.Wrap(err, "check owner reference") - } - - if !hasOwnerRef { - gvk := v1beta1.SchemeBuilder.GroupVersion.WithKind("PostgresCluster") - existing.OwnerReferences = []metav1.OwnerReference{{ - APIVersion: gvk.GroupVersion().String(), - Kind: gvk.Kind, - Name: cluster.GetName(), - UID: cluster.GetUID(), - BlockOwnerDeletion: ptr.To(true), - Controller: ptr.To(true), - }} - needsUpdate = true + if !clusterScoped { + hasOwnerRef, err := controllerutil.HasOwnerReference(existing.OwnerReferences, cluster, c.scheme) + if err != nil { + return errors.Wrap(err, "check owner reference") + } + + if !hasOwnerRef { + gvk := v1beta1.SchemeBuilder.GroupVersion.WithKind("PostgresCluster") + existing.OwnerReferences = []metav1.OwnerReference{{ + APIVersion: gvk.GroupVersion().String(), + Kind: gvk.Kind, + Name: cluster.GetName(), + UID: cluster.GetUID(), + BlockOwnerDeletion: ptr.To(true), + Controller: ptr.To(true), + }} + needsUpdate = true + } } if existing.Spec.Duration != nil && existing.Spec.Duration.Duration != caDuration { @@ -264,19 +450,16 @@ func (c *controller) ApplyCACertificate(ctx context.Context, cluster *v1beta1.Po cert := &v1.Certificate{ ObjectMeta: metav1.ObjectMeta{ Name: certName, - Namespace: cluster.Namespace, + Namespace: certNamespace, Labels: naming.WithPerconaLabels(map[string]string{ naming.LabelCluster: cluster.Name, }, cluster.Name, "", cluster.Labels[naming.LabelVersion]), }, Spec: v1.CertificateSpec{ - SecretName: certName, - CommonName: cluster.Name + "-ca", - IsCA: true, - IssuerRef: cmmeta.IssuerReference{ - Name: naming.CAIssuer(cluster).Name, - Kind: v1.IssuerKind, - }, + SecretName: certName, + CommonName: cluster.Name + "-ca", + IsCA: true, + IssuerRef: issuerRefValue, Duration: &metav1.Duration{Duration: caDuration}, RenewBefore: &metav1.Duration{Duration: DefaultRenewBefore}, PrivateKey: &v1.CertificatePrivateKey{ @@ -292,8 +475,10 @@ func (c *controller) ApplyCACertificate(ctx context.Context, cluster *v1beta1.Po }, } - if err := controllerutil.SetControllerReference(cluster, cert, c.scheme); err != nil { - return errors.Wrap(err, "failed to set controller reference") + if !clusterScoped { + if err := controllerutil.SetControllerReference(cluster, cert, c.scheme); err != nil { + return errors.Wrap(err, "failed to set controller reference") + } } if err := c.cl.Create(ctx, cert); err != nil { @@ -310,6 +495,12 @@ func (c *controller) ApplyClusterCertificate(ctx context.Context, cluster *v1bet return errors.New("dnsNames cannot be empty") } + mode, err := ResolveIssuerMode(ctx, c.cl, cluster) + if err != nil { + return errors.Wrap(err, "failed to resolve issuer mode") + } + wantIssuerRef := issuerRef(cluster, mode) + certName := ClusterCertificateName(cluster) certDuration := DefaultCertDuration @@ -318,7 +509,7 @@ func (c *controller) ApplyClusterCertificate(ctx context.Context, cluster *v1bet } existing := &v1.Certificate{} - err := c.cl.Get(ctx, types.NamespacedName{Name: certName, Namespace: cluster.Namespace}, existing) + err = c.cl.Get(ctx, types.NamespacedName{Name: certName, Namespace: cluster.Namespace}, existing) if err == nil { needsUpdate := false @@ -345,6 +536,11 @@ func (c *controller) ApplyClusterCertificate(ctx context.Context, cluster *v1bet needsUpdate = true } + if existing.Spec.IssuerRef != wantIssuerRef { + existing.Spec.IssuerRef = wantIssuerRef + needsUpdate = true + } + if !needsUpdate { return nil } @@ -365,13 +561,10 @@ func (c *controller) ApplyClusterCertificate(ctx context.Context, cluster *v1bet }, cluster.Name, "", cluster.Labels[naming.LabelVersion]), }, Spec: v1.CertificateSpec{ - SecretName: certName, - CommonName: cluster.Name + "-postgres", - DNSNames: dnsNames, - IssuerRef: cmmeta.IssuerReference{ - Name: naming.TLSIssuer(cluster).Name, - Kind: v1.IssuerKind, - }, + SecretName: certName, + CommonName: cluster.Name + "-postgres", + DNSNames: dnsNames, + IssuerRef: wantIssuerRef, Duration: &metav1.Duration{Duration: certDuration}, RenewBefore: &metav1.Duration{Duration: DefaultRenewBefore}, PrivateKey: &v1.CertificatePrivateKey{ @@ -412,6 +605,12 @@ func (c *controller) ApplyInstanceCertificate(ctx context.Context, cluster *v1be return errors.New("dnsNames cannot be empty") } + mode, err := ResolveIssuerMode(ctx, c.cl, cluster) + if err != nil { + return errors.Wrap(err, "failed to resolve issuer mode") + } + wantIssuerRef := issuerRef(cluster, mode) + certName := InstanceCertificateName(instanceName) secretName := instanceName + "-certs" @@ -421,7 +620,7 @@ func (c *controller) ApplyInstanceCertificate(ctx context.Context, cluster *v1be } existing := &v1.Certificate{} - err := c.cl.Get(ctx, types.NamespacedName{Name: certName, Namespace: cluster.Namespace}, existing) + err = c.cl.Get(ctx, types.NamespacedName{Name: certName, Namespace: cluster.Namespace}, existing) if err == nil { needsUpdate := false @@ -448,6 +647,11 @@ func (c *controller) ApplyInstanceCertificate(ctx context.Context, cluster *v1be needsUpdate = true } + if existing.Spec.IssuerRef != wantIssuerRef { + existing.Spec.IssuerRef = wantIssuerRef + needsUpdate = true + } + if !needsUpdate { return nil } @@ -468,13 +672,10 @@ func (c *controller) ApplyInstanceCertificate(ctx context.Context, cluster *v1be }, cluster.Name, "", cluster.Labels[naming.LabelVersion]), }, Spec: v1.CertificateSpec{ - SecretName: secretName, - CommonName: instanceName, - DNSNames: dnsNames, - IssuerRef: cmmeta.IssuerReference{ - Name: naming.TLSIssuer(cluster).Name, - Kind: v1.IssuerKind, - }, + SecretName: secretName, + CommonName: instanceName, + DNSNames: dnsNames, + IssuerRef: wantIssuerRef, Duration: &metav1.Duration{Duration: certDuration}, RenewBefore: &metav1.Duration{Duration: DefaultRenewBefore}, PrivateKey: &v1.CertificatePrivateKey{ @@ -514,6 +715,12 @@ func (c *controller) ApplyPGBouncerCertificate(ctx context.Context, cluster *v1b return errors.New("dnsNames cannot be empty") } + mode, err := ResolveIssuerMode(ctx, c.cl, cluster) + if err != nil { + return errors.Wrap(err, "failed to resolve issuer mode") + } + wantIssuerRef := issuerRef(cluster, mode) + secretMeta := naming.ClusterPGBouncer(cluster) certName := PGBouncerCertificateName(cluster) @@ -523,7 +730,7 @@ func (c *controller) ApplyPGBouncerCertificate(ctx context.Context, cluster *v1b } existing := &v1.Certificate{} - err := c.cl.Get(ctx, types.NamespacedName{Name: certName, Namespace: cluster.Namespace}, existing) + err = c.cl.Get(ctx, types.NamespacedName{Name: certName, Namespace: cluster.Namespace}, existing) if err == nil { needsUpdate := false @@ -550,6 +757,11 @@ func (c *controller) ApplyPGBouncerCertificate(ctx context.Context, cluster *v1b needsUpdate = true } + if existing.Spec.IssuerRef != wantIssuerRef { + existing.Spec.IssuerRef = wantIssuerRef + needsUpdate = true + } + if !needsUpdate { return nil } @@ -570,13 +782,10 @@ func (c *controller) ApplyPGBouncerCertificate(ctx context.Context, cluster *v1b }, cluster.Name, "pgbouncer", cluster.Labels[naming.LabelVersion]), }, Spec: v1.CertificateSpec{ - SecretName: secretMeta.Name + "-frontend-tls", - CommonName: truncateForCommonName(cluster.Name, "-pgbouncer"), - DNSNames: dnsNames, - IssuerRef: cmmeta.IssuerReference{ - Name: naming.TLSIssuer(cluster).Name, - Kind: v1.IssuerKind, - }, + SecretName: secretMeta.Name + "-frontend-tls", + CommonName: truncateForCommonName(cluster.Name, "-pgbouncer"), + DNSNames: dnsNames, + IssuerRef: wantIssuerRef, Duration: &metav1.Duration{Duration: certDuration}, RenewBefore: &metav1.Duration{Duration: DefaultRenewBefore}, PrivateKey: &v1.CertificatePrivateKey{ @@ -612,6 +821,12 @@ func (c *controller) ApplyPGBouncerCertificate(ctx context.Context, cluster *v1b // ApplyReplicationCertificate creates a cert-manager Certificate resource for the replication client. func (c *controller) ApplyReplicationCertificate(ctx context.Context, cluster *v1beta1.PostgresCluster) error { + mode, err := ResolveIssuerMode(ctx, c.cl, cluster) + if err != nil { + return errors.Wrap(err, "failed to resolve issuer mode") + } + wantIssuerRef := issuerRef(cluster, mode) + secretMeta := naming.ReplicationClientCertSecret(cluster) certName := ReplicationCertificateName(cluster) commonName := "_crunchyrepl" @@ -622,7 +837,7 @@ func (c *controller) ApplyReplicationCertificate(ctx context.Context, cluster *v } existing := &v1.Certificate{} - err := c.cl.Get(ctx, types.NamespacedName{Name: certName, Namespace: cluster.Namespace}, existing) + err = c.cl.Get(ctx, types.NamespacedName{Name: certName, Namespace: cluster.Namespace}, existing) if err == nil { needsUpdate := false @@ -649,6 +864,11 @@ func (c *controller) ApplyReplicationCertificate(ctx context.Context, cluster *v needsUpdate = true } + if existing.Spec.IssuerRef != wantIssuerRef { + existing.Spec.IssuerRef = wantIssuerRef + needsUpdate = true + } + if !needsUpdate { return nil } @@ -669,13 +889,10 @@ func (c *controller) ApplyReplicationCertificate(ctx context.Context, cluster *v }, cluster.Name, "", cluster.Labels[naming.LabelVersion]), }, Spec: v1.CertificateSpec{ - SecretName: secretMeta.Name, - CommonName: commonName, - DNSNames: []string{commonName}, - IssuerRef: cmmeta.IssuerReference{ - Name: naming.TLSIssuer(cluster).Name, - Kind: v1.IssuerKind, - }, + SecretName: secretMeta.Name, + CommonName: commonName, + DNSNames: []string{commonName}, + IssuerRef: wantIssuerRef, Duration: &metav1.Duration{Duration: certDuration}, RenewBefore: &metav1.Duration{Duration: DefaultRenewBefore}, PrivateKey: &v1.CertificatePrivateKey{ @@ -712,6 +929,12 @@ func (c *controller) ApplyReplicationCertificate(ctx context.Context, cluster *v // for the pgBackRest client used by all PostgreSQL instances to connect to the // repository host. func (c *controller) ApplyPGBackRestClientCertificate(ctx context.Context, cluster *v1beta1.PostgresCluster) error { + mode, err := ResolveIssuerMode(ctx, c.cl, cluster) + if err != nil { + return errors.Wrap(err, "failed to resolve issuer mode") + } + wantIssuerRef := issuerRef(cluster, mode) + secretMeta := naming.PGBackRestClientCertSecret(cluster) certName := PGBackRestClientCertificateName(cluster) @@ -725,7 +948,7 @@ func (c *controller) ApplyPGBackRestClientCertificate(ctx context.Context, clust commonName := "pgbackrest@" + string(cluster.GetUID()) existing := &v1.Certificate{} - err := c.cl.Get(ctx, types.NamespacedName{Name: certName, Namespace: cluster.Namespace}, existing) + err = c.cl.Get(ctx, types.NamespacedName{Name: certName, Namespace: cluster.Namespace}, existing) if err == nil { needsUpdate := false @@ -758,6 +981,11 @@ func (c *controller) ApplyPGBackRestClientCertificate(ctx context.Context, clust needsUpdate = true } + if existing.Spec.IssuerRef != wantIssuerRef { + existing.Spec.IssuerRef = wantIssuerRef + needsUpdate = true + } + if !needsUpdate { return nil } @@ -777,13 +1005,10 @@ func (c *controller) ApplyPGBackRestClientCertificate(ctx context.Context, clust }, cluster.Name, "", cluster.Labels[naming.LabelVersion]), }, Spec: v1.CertificateSpec{ - SecretName: secretMeta.Name, - CommonName: commonName, - DNSNames: []string{commonName}, - IssuerRef: cmmeta.IssuerReference{ - Name: naming.TLSIssuer(cluster).Name, - Kind: v1.IssuerKind, - }, + SecretName: secretMeta.Name, + CommonName: commonName, + DNSNames: []string{commonName}, + IssuerRef: wantIssuerRef, Duration: &metav1.Duration{Duration: certDuration}, RenewBefore: &metav1.Duration{Duration: DefaultRenewBefore}, PrivateKey: &v1.CertificatePrivateKey{ @@ -822,6 +1047,12 @@ func (c *controller) ApplyPGBackRestRepoCertificate(ctx context.Context, cluster return errors.New("dnsNames cannot be empty") } + mode, err := ResolveIssuerMode(ctx, c.cl, cluster) + if err != nil { + return errors.Wrap(err, "failed to resolve issuer mode") + } + wantIssuerRef := issuerRef(cluster, mode) + secretMeta := naming.PGBackRestRepoCertSecret(cluster) certName := PGBackRestRepoCertificateName(cluster) @@ -831,7 +1062,7 @@ func (c *controller) ApplyPGBackRestRepoCertificate(ctx context.Context, cluster } existing := &v1.Certificate{} - err := c.cl.Get(ctx, types.NamespacedName{Name: certName, Namespace: cluster.Namespace}, existing) + err = c.cl.Get(ctx, types.NamespacedName{Name: certName, Namespace: cluster.Namespace}, existing) if err == nil { needsUpdate := false @@ -858,6 +1089,11 @@ func (c *controller) ApplyPGBackRestRepoCertificate(ctx context.Context, cluster needsUpdate = true } + if existing.Spec.IssuerRef != wantIssuerRef { + existing.Spec.IssuerRef = wantIssuerRef + needsUpdate = true + } + if !needsUpdate { return nil } @@ -877,13 +1113,10 @@ func (c *controller) ApplyPGBackRestRepoCertificate(ctx context.Context, cluster }, cluster.Name, "", cluster.Labels[naming.LabelVersion]), }, Spec: v1.CertificateSpec{ - SecretName: secretMeta.Name, - CommonName: truncateForCommonName(cluster.Name, "-pgbackrest-repo"), - DNSNames: dnsNames, - IssuerRef: cmmeta.IssuerReference{ - Name: naming.TLSIssuer(cluster).Name, - Kind: v1.IssuerKind, - }, + SecretName: secretMeta.Name, + CommonName: truncateForCommonName(cluster.Name, "-pgbackrest-repo"), + DNSNames: dnsNames, + IssuerRef: wantIssuerRef, Duration: &metav1.Duration{Duration: certDuration}, RenewBefore: &metav1.Duration{Duration: DefaultRenewBefore}, PrivateKey: &v1.CertificatePrivateKey{ diff --git a/percona/certmanager/certmanager_test.go b/percona/certmanager/certmanager_test.go index bea8548a93..21ba20fe26 100644 --- a/percona/certmanager/certmanager_test.go +++ b/percona/certmanager/certmanager_test.go @@ -6,13 +6,18 @@ import ( "testing" "time" + "github.com/cert-manager/cert-manager/pkg/apis/certmanager" v1 "github.com/cert-manager/cert-manager/pkg/apis/certmanager/v1" + cmmeta "github.com/cert-manager/cert-manager/pkg/apis/meta/v1" "github.com/cert-manager/cert-manager/pkg/util/cmapichecker" + "github.com/pkg/errors" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" corev1 "k8s.io/api/core/v1" + k8serrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" "k8s.io/client-go/rest" sigs "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/client/fake" @@ -213,6 +218,66 @@ func TestApplyIssuer(t *testing.T) { err = ctrl.ApplyIssuer(t.Context(), cluster) require.NoError(t, err) }) + + t.Run("skip when external issuer", func(t *testing.T) { + cluster := testCluster() + cluster.Name = "tls-issuer-external" + cluster.Spec.TLS = &v1beta1.TLSSpec{ + IssuerConf: &cmmeta.IssuerReference{Name: "vault-issuer", Kind: "VaultClusterIssuer"}, + } + client := setupFakeClient(t, cluster) + ctrl := NewController(client, client.Scheme(), false) + + err := ctrl.ApplyIssuer(t.Context(), cluster) + require.NoError(t, err) + + list := &v1.IssuerList{} + require.NoError(t, client.List(t.Context(), list)) + assert.Empty(t, list.Items) + }) + + t.Run("create cluster-scoped TLS issuer when Kind is ClusterIssuer", func(t *testing.T) { + cluster := testCluster() + cluster.Name = "tls-issuer-cluster-scoped" + cluster.Spec.TLS = &v1beta1.TLSSpec{ + IssuerConf: &cmmeta.IssuerReference{Name: "shared-tls-issuer", Kind: v1.ClusterIssuerKind}, + } + client := setupFakeClient(t, cluster) + ctrl := NewController(client, client.Scheme(), false) + + err := ctrl.ApplyIssuer(t.Context(), cluster) + require.NoError(t, err) + + issuer := &v1.ClusterIssuer{} + err = client.Get(t.Context(), sigs.ObjectKey{Name: "shared-tls-issuer"}, issuer) + require.NoError(t, err) + require.NotNil(t, issuer.Spec.CA) + assert.Equal(t, naming.ClusterCACertSecret(cluster, CertManagerNamespace()).Name, issuer.Spec.CA.SecretName) + assert.Empty(t, issuer.OwnerReferences) + assert.Equal(t, naming.LabelPerconaManagedByValue, issuer.Labels[naming.LabelPerconaManagedBy]) + + // idempotent + err = ctrl.ApplyIssuer(t.Context(), cluster) + require.NoError(t, err) + }) + + t.Run("managed namespaced honors issuerConf name override", func(t *testing.T) { + cluster := testCluster() + cluster.Name = "tls-issuer-custom-name" + cluster.Spec.TLS = &v1beta1.TLSSpec{ + IssuerConf: &cmmeta.IssuerReference{Name: "my-custom-issuer-name"}, + } + client := setupFakeClient(t, cluster) + ctrl := NewController(client, client.Scheme(), false) + + err := ctrl.ApplyIssuer(t.Context(), cluster) + require.NoError(t, err) + + issuer := &v1.Issuer{} + err = client.Get(t.Context(), sigs.ObjectKey{Namespace: cluster.Namespace, Name: "my-custom-issuer-name"}, issuer) + require.NoError(t, err) + require.Len(t, issuer.OwnerReferences, 1) + }) } func TestApplyCAIssuer(t *testing.T) { @@ -245,6 +310,48 @@ func TestApplyCAIssuer(t *testing.T) { err = ctrl.ApplyCAIssuer(t.Context(), cluster) require.NoError(t, err) }) + + t.Run("skip when external issuer", func(t *testing.T) { + cluster := testCluster() + cluster.Name = "ca-test-external" + cluster.Spec.TLS = &v1beta1.TLSSpec{ + IssuerConf: &cmmeta.IssuerReference{Name: "vault-issuer", Kind: "VaultClusterIssuer"}, + } + client := setupFakeClient(t, cluster) + ctrl := NewController(client, client.Scheme(), false) + + err := ctrl.ApplyCAIssuer(t.Context(), cluster) + require.NoError(t, err) + + list := &v1.IssuerList{} + require.NoError(t, client.List(t.Context(), list)) + assert.Empty(t, list.Items) + }) + + t.Run("create cluster-scoped CA issuer when Kind is ClusterIssuer", func(t *testing.T) { + cluster := testCluster() + cluster.Name = "ca-test-cluster-scoped" + cluster.Spec.TLS = &v1beta1.TLSSpec{ + IssuerConf: &cmmeta.IssuerReference{Name: "shared-tls-issuer", Kind: v1.ClusterIssuerKind}, + } + client := setupFakeClient(t, cluster) + ctrl := NewController(client, client.Scheme(), false) + + err := ctrl.ApplyCAIssuer(t.Context(), cluster) + require.NoError(t, err) + + issuer := &v1.ClusterIssuer{} + meta := naming.ClusterCAIssuer(cluster) + err = client.Get(t.Context(), sigs.ObjectKey{Name: meta.Name}, issuer) + require.NoError(t, err) + assert.NotNil(t, issuer.Spec.SelfSigned) + assert.Empty(t, issuer.OwnerReferences) + assert.Equal(t, naming.LabelPerconaManagedByValue, issuer.Labels[naming.LabelPerconaManagedBy]) + + // idempotent + err = ctrl.ApplyCAIssuer(t.Context(), cluster) + require.NoError(t, err) + }) } func TestApplyCACertificate(t *testing.T) { @@ -290,6 +397,50 @@ func TestApplyCACertificate(t *testing.T) { err = ctrl.ApplyCACertificate(t.Context(), cluster) require.NoError(t, err) }) + + t.Run("skip when external issuer", func(t *testing.T) { + cluster := testCluster() + cluster.Name = "ca-cert-external" + cluster.Spec.TLS = &v1beta1.TLSSpec{ + IssuerConf: &cmmeta.IssuerReference{Name: "vault-issuer", Kind: "VaultClusterIssuer"}, + } + client := setupFakeClient(t, cluster) + ctrl := NewController(client, client.Scheme(), false) + + err := ctrl.ApplyCACertificate(t.Context(), cluster) + require.NoError(t, err) + + list := &v1.CertificateList{} + require.NoError(t, client.List(t.Context(), list)) + assert.Empty(t, list.Items) + }) + + t.Run("places CA certificate in cert-manager namespace when Kind is ClusterIssuer", func(t *testing.T) { + cluster := testCluster() + cluster.Name = "ca-cert-cluster-scoped" + cluster.Spec.TLS = &v1beta1.TLSSpec{ + IssuerConf: &cmmeta.IssuerReference{Name: "shared-tls-issuer", Kind: v1.ClusterIssuerKind}, + } + client := setupFakeClient(t, cluster) + ctrl := NewController(client, client.Scheme(), false) + + err := ctrl.ApplyCACertificate(t.Context(), cluster) + require.NoError(t, err) + + cert := &v1.Certificate{} + meta := naming.ClusterCACertSecret(cluster, CertManagerNamespace()) + err = client.Get(t.Context(), sigs.ObjectKey{Namespace: meta.Namespace, Name: meta.Name}, cert) + require.NoError(t, err) + assert.Equal(t, meta.Name, cert.Spec.SecretName) + assert.True(t, cert.Spec.IsCA) + assert.Equal(t, naming.ClusterCAIssuer(cluster).Name, cert.Spec.IssuerRef.Name) + assert.Equal(t, v1.ClusterIssuerKind, cert.Spec.IssuerRef.Kind) + assert.Empty(t, cert.OwnerReferences) + + // idempotent + err = ctrl.ApplyCACertificate(t.Context(), cluster) + require.NoError(t, err) + }) } func TestApplyClusterCertificate(t *testing.T) { @@ -1069,3 +1220,290 @@ func TestCustomTLSDurations(t *testing.T) { assert.Equal(t, customCertDuration, cert.Spec.Duration.Duration) }) } + +// forbiddenGetClient wraps a client.Client and returns a Forbidden error from +// Get for *v1.ClusterIssuer, simulating a cluster that hasn't granted RBAC +// for clusterissuers.cert-manager.io +type forbiddenGetClient struct { + sigs.Client +} + +func (f *forbiddenGetClient) Get(ctx context.Context, key sigs.ObjectKey, obj sigs.Object, opts ...sigs.GetOption) error { + if _, ok := obj.(*v1.ClusterIssuer); ok { + return k8serrors.NewForbidden( + schema.GroupResource{Group: "cert-manager.io", Resource: "clusterissuers"}, + key.Name, errors.New("forbidden")) + } + return f.Client.Get(ctx, key, obj, opts...) +} + +func TestCertManagerNamespace(t *testing.T) { + t.Run("defaults to cert-manager", func(t *testing.T) { + t.Setenv("CERTMANAGER_NAMESPACE", "") + assert.Equal(t, "cert-manager", CertManagerNamespace()) + }) + + t.Run("honors CERTMANAGER_NAMESPACE", func(t *testing.T) { + t.Setenv("CERTMANAGER_NAMESPACE", "custom-cm-ns") + assert.Equal(t, "custom-cm-ns", CertManagerNamespace()) + }) +} + +func TestResolveIssuerMode(t *testing.T) { + t.Run("nil TLS returns managed namespaced", func(t *testing.T) { + cluster := testCluster() + cl := setupFakeClient(t, cluster) + + mode, err := ResolveIssuerMode(t.Context(), cl, cluster) + require.NoError(t, err) + assert.Equal(t, IssuerModeManagedNamespaced, mode) + }) + + t.Run("nil IssuerConf returns managed namespaced", func(t *testing.T) { + cluster := testCluster() + cluster.Spec.TLS = &v1beta1.TLSSpec{} + cl := setupFakeClient(t, cluster) + + mode, err := ResolveIssuerMode(t.Context(), cl, cluster) + require.NoError(t, err) + assert.Equal(t, IssuerModeManagedNamespaced, mode) + }) + + t.Run("Kind Issuer returns managed namespaced", func(t *testing.T) { + cluster := testCluster() + cluster.Spec.TLS = &v1beta1.TLSSpec{ + IssuerConf: &cmmeta.IssuerReference{Name: "my-issuer", Kind: v1.IssuerKind}, + } + cl := setupFakeClient(t, cluster) + + mode, err := ResolveIssuerMode(t.Context(), cl, cluster) + require.NoError(t, err) + assert.Equal(t, IssuerModeManagedNamespaced, mode) + }) + + t.Run("Kind ClusterIssuer not found returns managed cluster", func(t *testing.T) { + cluster := testCluster() + cluster.Spec.TLS = &v1beta1.TLSSpec{ + IssuerConf: &cmmeta.IssuerReference{Name: "shared-issuer", Kind: v1.ClusterIssuerKind}, + } + cl := setupFakeClient(t, cluster) + + mode, err := ResolveIssuerMode(t.Context(), cl, cluster) + require.NoError(t, err) + assert.Equal(t, IssuerModeManagedCluster, mode) + }) + + t.Run("Kind ClusterIssuer readable and labeled as ours returns managed cluster", func(t *testing.T) { + cluster := testCluster() + cluster.Spec.TLS = &v1beta1.TLSSpec{ + IssuerConf: &cmmeta.IssuerReference{Name: "shared-issuer", Kind: v1.ClusterIssuerKind}, + } + existing := &v1.ClusterIssuer{ObjectMeta: metav1.ObjectMeta{ + Name: "shared-issuer", + Labels: map[string]string{naming.LabelPerconaManagedBy: naming.LabelPerconaManagedByValue}, + }} + cl := setupFakeClient(t, cluster, existing) + + mode, err := ResolveIssuerMode(t.Context(), cl, cluster) + require.NoError(t, err) + assert.Equal(t, IssuerModeManagedCluster, mode) + }) + + t.Run("Kind ClusterIssuer readable but not labeled as ours returns external", func(t *testing.T) { + cluster := testCluster() + cluster.Spec.TLS = &v1beta1.TLSSpec{ + IssuerConf: &cmmeta.IssuerReference{Name: "shared-issuer", Kind: v1.ClusterIssuerKind}, + } + // A pre-existing ClusterIssuer (e.g. ACME-backed) that this operator + // never created — no managed-by label. + existing := &v1.ClusterIssuer{ObjectMeta: metav1.ObjectMeta{Name: "shared-issuer"}} + cl := setupFakeClient(t, cluster, existing) + + mode, err := ResolveIssuerMode(t.Context(), cl, cluster) + require.NoError(t, err) + assert.Equal(t, IssuerModeExternal, mode) + }) + + t.Run("Kind ClusterIssuer forbidden returns external", func(t *testing.T) { + cluster := testCluster() + cluster.Spec.TLS = &v1beta1.TLSSpec{ + IssuerConf: &cmmeta.IssuerReference{Name: "shared-issuer", Kind: v1.ClusterIssuerKind}, + } + cl := &forbiddenGetClient{Client: setupFakeClient(t, cluster)} + + mode, err := ResolveIssuerMode(t.Context(), cl, cluster) + require.NoError(t, err) + assert.Equal(t, IssuerModeExternal, mode) + }) + + t.Run("third-party Kind returns external", func(t *testing.T) { + cluster := testCluster() + cluster.Spec.TLS = &v1beta1.TLSSpec{ + IssuerConf: &cmmeta.IssuerReference{Name: "vault-issuer", Kind: "VaultClusterIssuer", Group: "vault.example.com"}, + } + cl := setupFakeClient(t, cluster) + + mode, err := ResolveIssuerMode(t.Context(), cl, cluster) + require.NoError(t, err) + assert.Equal(t, IssuerModeExternal, mode) + }) +} + +func TestIssuerRef(t *testing.T) { + t.Run("managed namespaced without issuerConf uses generated name", func(t *testing.T) { + cluster := testCluster() + ref := issuerRef(cluster, IssuerModeManagedNamespaced) + assert.Equal(t, naming.TLSIssuer(cluster).Name, ref.Name) + assert.Equal(t, v1.IssuerKind, ref.Kind) + assert.Equal(t, certmanager.GroupName, ref.Group) + }) + + t.Run("managed namespaced with issuerConf name override", func(t *testing.T) { + cluster := testCluster() + cluster.Spec.TLS = &v1beta1.TLSSpec{ + IssuerConf: &cmmeta.IssuerReference{Name: "custom-tls-issuer"}, + } + ref := issuerRef(cluster, IssuerModeManagedNamespaced) + assert.Equal(t, "custom-tls-issuer", ref.Name) + assert.Equal(t, v1.IssuerKind, ref.Kind) + }) + + t.Run("managed cluster uses issuerConf name with ClusterIssuer kind", func(t *testing.T) { + cluster := testCluster() + cluster.Spec.TLS = &v1beta1.TLSSpec{ + IssuerConf: &cmmeta.IssuerReference{Name: "shared-issuer", Kind: v1.ClusterIssuerKind}, + } + ref := issuerRef(cluster, IssuerModeManagedCluster) + assert.Equal(t, "shared-issuer", ref.Name) + assert.Equal(t, v1.ClusterIssuerKind, ref.Kind) + }) + + t.Run("external uses issuerConf verbatim with default group", func(t *testing.T) { + cluster := testCluster() + cluster.Spec.TLS = &v1beta1.TLSSpec{ + IssuerConf: &cmmeta.IssuerReference{Name: "vault-issuer", Kind: "VaultClusterIssuer"}, + } + ref := issuerRef(cluster, IssuerModeExternal) + assert.Equal(t, "vault-issuer", ref.Name) + assert.Equal(t, "VaultClusterIssuer", ref.Kind) + assert.Equal(t, "cert-manager.io", ref.Group) + }) + + t.Run("external preserves explicit group", func(t *testing.T) { + cluster := testCluster() + cluster.Spec.TLS = &v1beta1.TLSSpec{ + IssuerConf: &cmmeta.IssuerReference{Name: "vault-issuer", Kind: "VaultClusterIssuer", Group: "vault.example.com"}, + } + ref := issuerRef(cluster, IssuerModeExternal) + assert.Equal(t, "vault.example.com", ref.Group) + }) +} + +func TestApplyCertificateIssuerRefDrift(t *testing.T) { + newIssuerConf := func(kind string) *v1beta1.TLSSpec { + return &v1beta1.TLSSpec{IssuerConf: &cmmeta.IssuerReference{Name: "vault-issuer", Kind: kind}} + } + + t.Run("cluster certificate switches to external issuerRef on update", func(t *testing.T) { + cluster := testCluster() + cluster.Name = "drift-cluster-cert" + client := setupFakeClient(t, cluster) + ctrl := NewController(client, client.Scheme(), false) + + dnsNames := []string{"drift-cluster-cert-primary.test-namespace.svc"} + require.NoError(t, ctrl.ApplyClusterCertificate(t.Context(), cluster, dnsNames)) + + cluster.Spec.TLS = newIssuerConf("VaultClusterIssuer") + require.NoError(t, ctrl.ApplyClusterCertificate(t.Context(), cluster, dnsNames)) + + cert := &v1.Certificate{} + secretName := naming.PostgresTLSSecret(cluster) + require.NoError(t, client.Get(t.Context(), sigs.ObjectKey{Namespace: cluster.Namespace, Name: secretName.Name}, cert)) + assert.Equal(t, "vault-issuer", cert.Spec.IssuerRef.Name) + assert.Equal(t, "VaultClusterIssuer", cert.Spec.IssuerRef.Kind) + }) + + t.Run("instance certificate switches issuerRef on update", func(t *testing.T) { + cluster := testCluster() + cluster.Name = "drift-instance-cert" + client := setupFakeClient(t, cluster) + ctrl := NewController(client, client.Scheme(), false) + + instanceName := "drift-instance-cert-instance-0" + dnsNames := []string{instanceName + ".test-namespace.svc"} + require.NoError(t, ctrl.ApplyInstanceCertificate(t.Context(), cluster, instanceName, dnsNames)) + + cluster.Spec.TLS = newIssuerConf("VaultClusterIssuer") + require.NoError(t, ctrl.ApplyInstanceCertificate(t.Context(), cluster, instanceName, dnsNames)) + + cert := &v1.Certificate{} + require.NoError(t, client.Get(t.Context(), sigs.ObjectKey{Namespace: cluster.Namespace, Name: instanceName + "-cert"}, cert)) + assert.Equal(t, "vault-issuer", cert.Spec.IssuerRef.Name) + }) + + t.Run("pgbouncer certificate switches issuerRef on update", func(t *testing.T) { + cluster := testCluster() + cluster.Name = "drift-pgbouncer-cert" + client := setupFakeClient(t, cluster) + ctrl := NewController(client, client.Scheme(), false) + + dnsNames := []string{"drift-pgbouncer-cert-pgbouncer.test-namespace.svc"} + require.NoError(t, ctrl.ApplyPGBouncerCertificate(t.Context(), cluster, dnsNames)) + + cluster.Spec.TLS = newIssuerConf("VaultClusterIssuer") + require.NoError(t, ctrl.ApplyPGBouncerCertificate(t.Context(), cluster, dnsNames)) + + cert := &v1.Certificate{} + require.NoError(t, client.Get(t.Context(), sigs.ObjectKey{Namespace: cluster.Namespace, Name: cluster.Name + "-pgbouncer-cert"}, cert)) + assert.Equal(t, "vault-issuer", cert.Spec.IssuerRef.Name) + }) + + t.Run("replication certificate switches issuerRef on update", func(t *testing.T) { + cluster := testCluster() + cluster.Name = "drift-replication-cert" + client := setupFakeClient(t, cluster) + ctrl := NewController(client, client.Scheme(), false) + + require.NoError(t, ctrl.ApplyReplicationCertificate(t.Context(), cluster)) + + cluster.Spec.TLS = newIssuerConf("VaultClusterIssuer") + require.NoError(t, ctrl.ApplyReplicationCertificate(t.Context(), cluster)) + + cert := &v1.Certificate{} + require.NoError(t, client.Get(t.Context(), sigs.ObjectKey{Namespace: cluster.Namespace, Name: cluster.Name + "-replication-cert"}, cert)) + assert.Equal(t, "vault-issuer", cert.Spec.IssuerRef.Name) + }) + + t.Run("pgbackrest client certificate switches issuerRef on update", func(t *testing.T) { + cluster := testCluster() + cluster.Name = "drift-pgbr-client-cert" + client := setupFakeClient(t, cluster) + ctrl := NewController(client, client.Scheme(), false) + + require.NoError(t, ctrl.ApplyPGBackRestClientCertificate(t.Context(), cluster)) + + cluster.Spec.TLS = newIssuerConf("VaultClusterIssuer") + require.NoError(t, ctrl.ApplyPGBackRestClientCertificate(t.Context(), cluster)) + + cert := &v1.Certificate{} + require.NoError(t, client.Get(t.Context(), sigs.ObjectKey{Namespace: cluster.Namespace, Name: cluster.Name + "-pgbackrest-client-cert"}, cert)) + assert.Equal(t, "vault-issuer", cert.Spec.IssuerRef.Name) + }) + + t.Run("pgbackrest repo certificate switches issuerRef on update", func(t *testing.T) { + cluster := testCluster() + cluster.Name = "drift-pgbr-repo-cert" + client := setupFakeClient(t, cluster) + ctrl := NewController(client, client.Scheme(), false) + + dnsNames := []string{cluster.Name + "-repo-host-0." + cluster.Name + "-pgbackrest.test-namespace.svc"} + require.NoError(t, ctrl.ApplyPGBackRestRepoCertificate(t.Context(), cluster, dnsNames)) + + cluster.Spec.TLS = newIssuerConf("VaultClusterIssuer") + require.NoError(t, ctrl.ApplyPGBackRestRepoCertificate(t.Context(), cluster, dnsNames)) + + cert := &v1.Certificate{} + require.NoError(t, client.Get(t.Context(), sigs.ObjectKey{Namespace: cluster.Namespace, Name: cluster.Name + "-pgbackrest-repo-cert"}, cert)) + assert.Equal(t, "vault-issuer", cert.Spec.IssuerRef.Name) + }) +} diff --git a/percona/runtime/runtime.go b/percona/runtime/runtime.go index a561e7b6cd..0041172ad3 100644 --- a/percona/runtime/runtime.go +++ b/percona/runtime/runtime.go @@ -5,8 +5,10 @@ import ( "strings" "time" + cmv1 "github.com/cert-manager/cert-manager/pkg/apis/certmanager/v1" "k8s.io/client-go/rest" "sigs.k8s.io/controller-runtime/pkg/cache" + "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/manager" r "github.com/percona/percona-postgresql-operator/v2/internal/controller/runtime" @@ -19,6 +21,12 @@ const refreshInterval time.Duration = 60 * time.Minute const ElectionID string = "08db3feb.percona.com" +func ClientCacheOptions() *client.CacheOptions { + return &client.CacheOptions{ + DisableFor: []client.Object{&cmv1.ClusterIssuer{}}, + } +} + // CreateRuntimeManager wraps internal/controller/runtime.NewManager and modifies the given options: // - Fully overwrites the Cache field // - Sets Cache.SyncPeriod to refreshInterval const @@ -42,6 +50,8 @@ func CreateRuntimeManager(config *rest.Config, features feature.MutableGate, opt options.Cache.DefaultNamespaces = namespaces } + options.Client.Cache = ClientCacheOptions() + options.BaseContext = func() context.Context { ctx := context.Background() return feature.NewContext(ctx, features) diff --git a/pkg/apis/upstream.pgv2.percona.com/v1beta1/postgrescluster_types.go b/pkg/apis/upstream.pgv2.percona.com/v1beta1/postgrescluster_types.go index 04e8d526c5..37fe1e620b 100644 --- a/pkg/apis/upstream.pgv2.percona.com/v1beta1/postgrescluster_types.go +++ b/pkg/apis/upstream.pgv2.percona.com/v1beta1/postgrescluster_types.go @@ -9,6 +9,7 @@ import ( "fmt" "reflect" + cmmeta "github.com/cert-manager/cert-manager/pkg/apis/meta/v1" gover "github.com/hashicorp/go-version" "github.com/pkg/errors" corev1 "k8s.io/api/core/v1" @@ -251,6 +252,8 @@ type TLSSpec struct { CAValidityDuration *metav1.Duration `json:"caValidityDuration,omitempty"` // +optional PGBackRestCertValidityDuration *metav1.Duration `json:"pgBackRestCertValidityDuration,omitempty"` + // +optional + IssuerConf *cmmeta.IssuerReference `json:"issuerConf,omitempty"` } // DataSource defines data sources for a new PostgresCluster. diff --git a/pkg/apis/upstream.pgv2.percona.com/v1beta1/zz_generated.deepcopy.go b/pkg/apis/upstream.pgv2.percona.com/v1beta1/zz_generated.deepcopy.go index 8bb1045af0..19c0f207db 100644 --- a/pkg/apis/upstream.pgv2.percona.com/v1beta1/zz_generated.deepcopy.go +++ b/pkg/apis/upstream.pgv2.percona.com/v1beta1/zz_generated.deepcopy.go @@ -9,6 +9,7 @@ package v1beta1 import ( + metav1 "github.com/cert-manager/cert-manager/pkg/apis/meta/v1" corev1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" @@ -2678,6 +2679,11 @@ func (in *TLSSpec) DeepCopyInto(out *TLSSpec) { *out = new(v1.Duration) **out = **in } + if in.IssuerConf != nil { + in, out := &in.IssuerConf, &out.IssuerConf + *out = new(metav1.IssuerReference) + **out = **in + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TLSSpec.