diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d4af55c07d..2c4a897690 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -125,6 +125,7 @@ jobs: make cluster make install make install-minio + make install-cert-manager make net - name: Test @@ -160,6 +161,7 @@ jobs: make registry make install-ent make install-minio + make install-cert-manager make net - name: Test diff --git a/.vscode/launch.json b/.vscode/launch.json index 7abedb620c..a82a9c6979 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -25,7 +25,7 @@ "RELATED_IMAGE_EXPORTER": "prom/mysqld-exporter:v0.15.1", "RELATED_IMAGE_EXPORTER_MAXSCALE": "mariadb/maxscale-prometheus-exporter-ubi:latest", "MARIADB_GALERA_LIB_PATH": "/usr/lib/galera/libgalera_smm.so", - "MARIADB_ENTRYPOINT_VERSION": "11.4", + "MARIADB_DEFAULT_VERSION": "11.4", "WATCH_NAMESPACE": "", } }, @@ -50,7 +50,7 @@ "RELATED_IMAGE_EXPORTER": "prom/mysqld-exporter:v0.15.1", "RELATED_IMAGE_EXPORTER_MAXSCALE": "mariadb/maxscale-prometheus-exporter-ubi:latest", "MARIADB_GALERA_LIB_PATH": "/usr/lib/galera/libgalera_enterprise_smm.so", - "MARIADB_ENTRYPOINT_VERSION": "10.6", + "MARIADB_DEFAULT_VERSION": "10.6", "WATCH_NAMESPACE": "", } }, @@ -97,7 +97,7 @@ "--s3-endpoint=minio:9000", "--s3-region=us-east-1", "--s3-tls", - "--s3-ca-cert-path=/tmp/certificate-authority/tls.crt", + "--s3-ca-cert-path=/tmp/pki/ca/tls.crt", "--compression=gzip", "--log-dev", "--log-level=info", @@ -127,7 +127,7 @@ "--s3-endpoint=minio:9000", "--s3-region=us-east-1", "--s3-tls", - "--s3-ca-cert-path=/tmp/certificate-authority/tls.crt", + "--s3-ca-cert-path=/tmp/pki/ca/tls.crt", "--log-dev", "--log-level=info", "--log-time-encoder=iso8601", diff --git a/Makefile b/Makefile index 3516fa5168..d1efb7a84c 100644 --- a/Makefile +++ b/Makefile @@ -23,7 +23,7 @@ IMG_ENT ?= $(IMG_ENT_NAME):$(VERSION) # mariadb RELATED_IMAGE_MARIADB_NAME ?= docker-registry1.mariadb.com/library/mariadb -RELATED_IMAGE_MARIADB_VERSION ?= 11.4.3 +RELATED_IMAGE_MARIADB_VERSION ?= 11.4.4 RELATED_IMAGE_MARIADB ?= $(RELATED_IMAGE_MARIADB_NAME):$(RELATED_IMAGE_MARIADB_VERSION) RELATED_IMAGE_MARIADB_ENT_NAME ?= docker-registry.mariadb.com/enterprise-server @@ -66,8 +66,8 @@ MARIADB_DOCKER_REPO ?= https://github.com/MariaDB/mariadb-docker MARIADB_DOCKER_COMMIT_HASH ?= a6b360fc45b1a8fcd63b87ab69d4ce43566a7c06 MARIADB_ENTRYPOINT_PATH ?= pkg/embed/mariadb-docker -MARIADB_ENTRYPOINT_VERSION ?= 11.4 -MARIADB_ENTRYPOINT_VERSION_ENT ?= 10.6 +MARIADB_DEFAULT_VERSION ?= 11.4 +MARIADB_DEFAULT_VERSION_ENT ?= 10.6 DOCKER_CONFIG ?= $(HOME)/.docker/config.json diff --git a/api/v1alpha1/base_types.go b/api/v1alpha1/base_types.go index 68458e6585..5e5204d8da 100644 --- a/api/v1alpha1/base_types.go +++ b/api/v1alpha1/base_types.go @@ -5,12 +5,14 @@ import ( "fmt" "time" + cmmeta "github.com/cert-manager/cert-manager/pkg/apis/meta/v1" "github.com/mariadb-operator/mariadb-operator/pkg/webhook" cron "github.com/robfig/cron/v3" corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/types" "k8s.io/apimachinery/pkg/util/intstr" + "k8s.io/apimachinery/pkg/util/validation/field" "k8s.io/utils/ptr" ) @@ -516,7 +518,7 @@ type SQLTemplate struct { CleanupPolicy *CleanupPolicy `json:"cleanupPolicy,omitempty"` } -type TLS struct { +type TLSS3 struct { // Enabled is a flag to enable TLS. // +optional // +operator-sdk:csv:customresourcedefinitions:type=spec,xDescriptors={"urn:alm:descriptor:com.tectonic.ui:booleanSwitch"} @@ -560,7 +562,7 @@ type S3 struct { // TLS provides the configuration required to establish TLS connections with S3. // +optional // +operator-sdk:csv:customresourcedefinitions:type=spec - TLS *TLS `json:"tls,omitempty"` + TLS *TLSS3 `json:"tls,omitempty"` } // Metadata defines the metadata to added to resources. @@ -832,3 +834,51 @@ type Exporter struct { // +operator-sdk:csv:customresourcedefinitions:type=spec,xDescriptors={"urn:alm:descriptor:com.tectonic.ui:advanced"} PriorityClassName *string `json:"priorityClassName,omitempty" webhook:"inmutable"` } + +// CertificateStatus represents the current status of a TLS certificate. +type CertificateStatus struct { + // NotAfter indicates that the certificate is not valid after the given date. + // +operator-sdk:csv:customresourcedefinitions:type=status + NotAfter metav1.Time `json:"notAfter,omitempty"` + // NotBefore indicates that the certificate is not valid before the given date. + // +operator-sdk:csv:customresourcedefinitions:type=status + NotBefore metav1.Time `json:"notBefore,omitempty"` + // Subject is the subject of the current certificate. + // +operator-sdk:csv:customresourcedefinitions:type=status + Subject string `json:"subject"` + // Issuer is the issuer of the current certificate. + // +operator-sdk:csv:customresourcedefinitions:type=status + Issuer string `json:"issuer"` +} + +type tlsValidationItem struct { + tlsValue interface{} + caSecretRef *LocalObjectReference + caFieldPath string + certSecretRef *LocalObjectReference + certFieldPath string + certIssuerRef *cmmeta.ObjectReference + certIssuerFieldPath string +} + +func validateTLSCert(item *tlsValidationItem) error { + if item.certSecretRef != nil && item.certIssuerRef != nil { + return field.Invalid( + field.NewPath("spec").Child("tls"), + item.tlsValue, + fmt.Sprintf( + "'%s' and '%s' are mutually exclusive. Only one of them must be set at a time.", + item.certFieldPath, + item.certIssuerFieldPath, + ), + ) + } + if item.caSecretRef == nil && item.certSecretRef != nil { + return field.Invalid( + field.NewPath("spec").Child("tls"), + item.tlsValue, + fmt.Sprintf("'%s' must be set when '%s' is set", item.caFieldPath, item.certFieldPath), + ) + } + return nil +} diff --git a/api/v1alpha1/base_types_test.go b/api/v1alpha1/base_types_test.go index 8f494a3419..8420254829 100644 --- a/api/v1alpha1/base_types_test.go +++ b/api/v1alpha1/base_types_test.go @@ -1,6 +1,7 @@ package v1alpha1 import ( + cmmeta "github.com/cert-manager/cert-manager/pkg/apis/meta/v1" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" corev1 "k8s.io/api/core/v1" @@ -627,4 +628,64 @@ var _ = Describe("Base types", func() { ), ) }) + + Context("When validating TLS", func() { + DescribeTable( + "Should validate", + func( + item *tlsValidationItem, + wantErr bool, + ) { + err := validateTLSCert(item) + if wantErr { + Expect(err).To(HaveOccurred()) + } else { + Expect(err).ToNot(HaveOccurred()) + } + }, + Entry( + "empty", + &tlsValidationItem{}, + false, + ), + Entry( + "certSecretRef and caSecretRef", + &tlsValidationItem{ + certSecretRef: &LocalObjectReference{Name: "cert-secret"}, + caSecretRef: &LocalObjectReference{Name: "ca-secret"}, + }, + false, + ), + Entry( + "certIssuerRef", + &tlsValidationItem{ + certIssuerRef: &cmmeta.ObjectReference{Name: "cert-issuer"}, + }, + false, + ), + Entry( + "certIssuerRef and caSecretRef", + &tlsValidationItem{ + certIssuerRef: &cmmeta.ObjectReference{Name: "cert-issuer"}, + caSecretRef: &LocalObjectReference{Name: "ca-secret"}, + }, + false, + ), + Entry( + "certSecretRef set without caSecretRef", + &tlsValidationItem{ + certSecretRef: &LocalObjectReference{Name: "cert-secret"}, + }, + true, + ), + Entry( + "certSecretRef and certIssuerRef", + &tlsValidationItem{ + certSecretRef: &LocalObjectReference{Name: "cert-secret"}, + certIssuerRef: &cmmeta.ObjectReference{Name: "cert-issuer"}, + }, + true, + ), + ) + }) }) diff --git a/api/v1alpha1/connection_indexes.go b/api/v1alpha1/connection_indexes.go new file mode 100644 index 0000000000..a5e954335e --- /dev/null +++ b/api/v1alpha1/connection_indexes.go @@ -0,0 +1,55 @@ +package v1alpha1 + +import ( + "context" + "fmt" + + "github.com/mariadb-operator/mariadb-operator/pkg/metadata" + "github.com/mariadb-operator/mariadb-operator/pkg/predicate" + "github.com/mariadb-operator/mariadb-operator/pkg/watch" + corev1 "k8s.io/api/core/v1" + ctrlbuilder "sigs.k8s.io/controller-runtime/pkg/builder" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/manager" +) + +const connectionPasswordSecretFieldPath = ".spec.passwordSecretKeyRef.name" + +// IndexerFuncForFieldPath returns an indexer function for a given field path. +func (c *Connection) IndexerFuncForFieldPath(fieldPath string) (client.IndexerFunc, error) { + switch fieldPath { + case connectionPasswordSecretFieldPath: + return func(obj client.Object) []string { + connection, ok := obj.(*Connection) + if !ok { + return nil + } + if connection.Spec.PasswordSecretKeyRef.LocalObjectReference.Name != "" { + return []string{connection.Spec.PasswordSecretKeyRef.LocalObjectReference.Name} + } + return nil + }, nil + default: + return nil, fmt.Errorf("unsupported field path: %s", fieldPath) + } +} + +// IndexConnection watches and indexes external resources referred by Connection resources. +func IndexConnection(ctx context.Context, mgr manager.Manager, builder *ctrlbuilder.Builder, client client.Client) error { + watcherIndexer := watch.NewWatcherIndexer(mgr, builder, client) + + if err := watcherIndexer.Watch( + ctx, + &corev1.Secret{}, + &Connection{}, + &ConnectionList{}, + connectionPasswordSecretFieldPath, + ctrlbuilder.WithPredicates( + predicate.PredicateWithLabel(metadata.WatchLabel), + ), + ); err != nil { + return fmt.Errorf("error watching: %v", err) + } + + return nil +} diff --git a/api/v1alpha1/connection_types.go b/api/v1alpha1/connection_types.go index 4fc9bd2be7..29f3ec90ac 100644 --- a/api/v1alpha1/connection_types.go +++ b/api/v1alpha1/connection_types.go @@ -2,7 +2,6 @@ package v1alpha1 import ( "errors" - "fmt" "github.com/mariadb-operator/mariadb-operator/pkg/statefulset" "k8s.io/apimachinery/pkg/api/meta" @@ -80,6 +79,11 @@ type ConnectionSpec struct { // +kubebuilder:validation:Required // +operator-sdk:csv:customresourcedefinitions:type=spec PasswordSecretKeyRef SecretKeySelector `json:"passwordSecretKeyRef"` + // TLSClientCertSecretRef is a reference to a Kubernetes TLS Secret used as authentication when checking the connection health. + // If not provided, the client certificate provided by the referred MariaDB is used. + // +optional + // +operator-sdk:csv:customresourcedefinitions:type=spec + TLSClientCertSecretRef *LocalObjectReference `json:"tlsClientCertSecretRef,omitempty"` // Host to connect to. If not provided, it defaults to the MariaDB host or to the MaxScale host. // +optional // +operator-sdk:csv:customresourcedefinitions:type=spec,xDescriptors={"urn:alm:descriptor:com.tectonic.ui:number","urn:alm:descriptor:com.tectonic.ui:advanced"} @@ -167,28 +171,6 @@ func (c *Connection) SecretKey() string { return defaultConnSecretKey } -// ConnectionPasswordSecretFieldPath is the path related to the password Secret field. -const ConnectionPasswordSecretFieldPath = ".spec.passwordSecretKeyRef.name" - -// IndexerFuncForFieldPath returns an indexer function for a given field path. -func (c *Connection) IndexerFuncForFieldPath(fieldPath string) (client.IndexerFunc, error) { - switch fieldPath { - case ConnectionPasswordSecretFieldPath: - return func(obj client.Object) []string { - connection, ok := obj.(*Connection) - if !ok { - return nil - } - if connection.Spec.PasswordSecretKeyRef.LocalObjectReference.Name != "" { - return []string{connection.Spec.PasswordSecretKeyRef.LocalObjectReference.Name} - } - return nil - }, nil - default: - return nil, fmt.Errorf("unsupported field path: %s", fieldPath) - } -} - //+kubebuilder:object:root=true // ConnectionList contains a list of Connection diff --git a/api/v1alpha1/event_types.go b/api/v1alpha1/event_types.go index 0792e392b4..52b4e537a6 100644 --- a/api/v1alpha1/event_types.go +++ b/api/v1alpha1/event_types.go @@ -50,4 +50,7 @@ const ( // ReasonCRDNotFound indicates that a third party CRD is not present in the cluster. ReasonCRDNotFound = "CRDNotFound" + + // SecretKeyNotFound indicates that a required Secret key could not be found. + SecretKeyNotFound = "SecretKeyNotFound" ) diff --git a/api/v1alpha1/grant_indexes.go b/api/v1alpha1/grant_indexes.go new file mode 100644 index 0000000000..823bbc8021 --- /dev/null +++ b/api/v1alpha1/grant_indexes.go @@ -0,0 +1,56 @@ +package v1alpha1 + +import ( + "context" + "fmt" + + "github.com/mariadb-operator/mariadb-operator/pkg/watch" + ctrlbuilder "sigs.k8s.io/controller-runtime/pkg/builder" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/event" + "sigs.k8s.io/controller-runtime/pkg/manager" + "sigs.k8s.io/controller-runtime/pkg/predicate" +) + +const grantUsernameFieldPath = ".spec.username" + +// IndexerFuncForFieldPath returns an indexer function for a given field path. +func (g *Grant) IndexerFuncForFieldPath(fieldPath string) (client.IndexerFunc, error) { + switch fieldPath { + case grantUsernameFieldPath: + return func(obj client.Object) []string { + grant, ok := obj.(*Grant) + if !ok { + return nil + } + if grant.Spec.Username != "" { + return []string{grant.Spec.Username} + } + return nil + }, nil + default: + return nil, fmt.Errorf("unsupported field path: %s", fieldPath) + } +} + +// IndexGrant watches and indexes external resources referred by Grant resources. +func IndexGrant(ctx context.Context, mgr manager.Manager, builder *ctrlbuilder.Builder, client client.Client) error { + watcherIndexer := watch.NewWatcherIndexer(mgr, builder, client) + + if err := watcherIndexer.Watch( + ctx, + &User{}, + &Grant{}, + &GrantList{}, + grantUsernameFieldPath, + ctrlbuilder.WithPredicates(predicate.Funcs{ + CreateFunc: func(ce event.CreateEvent) bool { + return true + }, + }), + ); err != nil { + return fmt.Errorf("error watching: %v", err) + } + + return nil +} diff --git a/api/v1alpha1/grant_types.go b/api/v1alpha1/grant_types.go index 87ae66e855..a399243bc6 100644 --- a/api/v1alpha1/grant_types.go +++ b/api/v1alpha1/grant_types.go @@ -5,7 +5,6 @@ import ( "k8s.io/apimachinery/pkg/api/meta" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "sigs.k8s.io/controller-runtime/pkg/client" ctrlclient "sigs.k8s.io/controller-runtime/pkg/client" ) @@ -121,28 +120,6 @@ func (g *Grant) HostnameOrDefault() string { return "%" } -// GrantUsernameFieldPath is the path related to the username field. -const GrantUsernameFieldPath = ".spec.username" - -// IndexerFuncForFieldPath returns an indexer function for a given field path. -func (g *Grant) IndexerFuncForFieldPath(fieldPath string) (client.IndexerFunc, error) { - switch fieldPath { - case GrantUsernameFieldPath: - return func(obj client.Object) []string { - grant, ok := obj.(*Grant) - if !ok { - return nil - } - if grant.Spec.Username != "" { - return []string{grant.Spec.Username} - } - return nil - }, nil - default: - return nil, fmt.Errorf("unsupported field path: %s", fieldPath) - } -} - //+kubebuilder:object:root=true // GrantList contains a list of Grant diff --git a/api/v1alpha1/kubernetes_types.go b/api/v1alpha1/kubernetes_types.go index 09effa8452..855e7d6694 100644 --- a/api/v1alpha1/kubernetes_types.go +++ b/api/v1alpha1/kubernetes_types.go @@ -184,12 +184,28 @@ func (e HTTPGetAction) ToKubernetesType() corev1.HTTPGetAction { } } +// Refer to the Kubernetes docs: https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.31/#tcpsocketaction-v1-core. +type TCPSocketAction struct { + Port intstr.IntOrString `json:"port"` + // +optional + Host string `json:"host,omitempty"` +} + +func (e TCPSocketAction) ToKubernetesType() corev1.TCPSocketAction { + return corev1.TCPSocketAction{ + Port: e.Port, + Host: e.Host, + } +} + // Refer to the Kubernetes docs: https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.31/#probe-v1-core. type ProbeHandler struct { // +optional Exec *ExecAction `json:"exec,omitempty"` // +optional HTTPGet *HTTPGetAction `json:"httpGet,omitempty"` + // +optional + TCPSocket *TCPSocketAction `json:"tcpSocket,omitempty"` } func (p ProbeHandler) ToKubernetesType() corev1.ProbeHandler { @@ -200,6 +216,9 @@ func (p ProbeHandler) ToKubernetesType() corev1.ProbeHandler { if p.HTTPGet != nil { probe.HTTPGet = ptr.To(p.HTTPGet.ToKubernetesType()) } + if p.TCPSocket != nil { + probe.TCPSocket = ptr.To(p.TCPSocket.ToKubernetesType()) + } return probe } diff --git a/api/v1alpha1/mariadb_galera_types.go b/api/v1alpha1/mariadb_galera_types.go index 009f4637b3..20c5fa3493 100644 --- a/api/v1alpha1/mariadb_galera_types.go +++ b/api/v1alpha1/mariadb_galera_types.go @@ -165,10 +165,14 @@ type GaleraAgent struct { // +kubebuilder:validation:Enum=Always;Never;IfNotPresent // +operator-sdk:csv:customresourcedefinitions:type=spec,xDescriptors={"urn:alm:descriptor:com.tectonic.ui:imagePullPolicy"} ImagePullPolicy corev1.PullPolicy `json:"imagePullPolicy,omitempty"` - // Port where the agent will be listening for connections. + // Port where the agent will be listening for API connections. // +optional // +operator-sdk:csv:customresourcedefinitions:type=spec Port int32 `json:"port,omitempty"` + // Port where the agent will be listening for probe connections. + // +optional + // +operator-sdk:csv:customresourcedefinitions:type=spec + ProbePort int32 `json:"probePort,omitempty"` // KubernetesAuth to be used by the agent container // +optional // +operator-sdk:csv:customresourcedefinitions:type=spec @@ -191,6 +195,9 @@ func (r *GaleraAgent) SetDefaults(mariadb *MariaDB, env *environment.OperatorEnv if r.Port == 0 { r.Port = 5555 } + if r.ProbePort == 0 { + r.ProbePort = 5566 + } currentNamespaceOnly, err := env.CurrentNamespaceOnly() if err != nil { diff --git a/api/v1alpha1/mariadb_galera_types_test.go b/api/v1alpha1/mariadb_galera_types_test.go index 0d9324a429..56b46436d5 100644 --- a/api/v1alpha1/mariadb_galera_types_test.go +++ b/api/v1alpha1/mariadb_galera_types_test.go @@ -70,8 +70,9 @@ var _ = Describe("MariaDB Galera types", func() { AutomaticFailover: ptr.To(true), }, Agent: GaleraAgent{ - Image: "ghcr.io/mariadb-operator/mariadb-operator:v0.0.26", - Port: 5555, + Image: "ghcr.io/mariadb-operator/mariadb-operator:v0.0.26", + Port: 5555, + ProbePort: 5566, KubernetesAuth: &KubernetesAuth{ Enabled: true, }, @@ -154,8 +155,9 @@ var _ = Describe("MariaDB Galera types", func() { AutomaticFailover: ptr.To(false), }, Agent: GaleraAgent{ - Image: "mariadb/mariadb-operator-enterprise:v0.0.26", - Port: 5555, + Image: "mariadb/mariadb-operator-enterprise:v0.0.26", + Port: 5555, + ProbePort: 5566, KubernetesAuth: &KubernetesAuth{ Enabled: false, }, @@ -221,8 +223,9 @@ var _ = Describe("MariaDB Galera types", func() { AutomaticFailover: ptr.To(true), }, Agent: GaleraAgent{ - Image: "ghcr.io/mariadb-operator/mariadb-operator:v0.0.26", - Port: 5555, + Image: "ghcr.io/mariadb-operator/mariadb-operator:v0.0.26", + Port: 5555, + ProbePort: 5566, KubernetesAuth: &KubernetesAuth{ Enabled: true, }, diff --git a/api/v1alpha1/mariadb_indexes.go b/api/v1alpha1/mariadb_indexes.go new file mode 100644 index 0000000000..395dda6d25 --- /dev/null +++ b/api/v1alpha1/mariadb_indexes.go @@ -0,0 +1,142 @@ +package v1alpha1 + +import ( + "context" + "fmt" + + "github.com/mariadb-operator/mariadb-operator/pkg/metadata" + "github.com/mariadb-operator/mariadb-operator/pkg/predicate" + "github.com/mariadb-operator/mariadb-operator/pkg/watch" + corev1 "k8s.io/api/core/v1" + ctrlbuilder "sigs.k8s.io/controller-runtime/pkg/builder" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/manager" +) + +const ( + mariadbMyCnfConfigMapFieldPath = ".spec.myCnfConfigMapKeyRef.name" + + mariadbMetricsPasswordSecretFieldPath = ".spec.metrics.passwordSecretKeyRef" + + mariadbTLSServerCASecretFieldPath = ".spec.tls.serverCASecretRef" + mariadbTLSServerCertSecretFieldPath = ".spec.tls.serverCertSecretRef" + mariadbTLSClientCASecretFieldPath = ".spec.tls.clientCASecretRef" + mariadbTLSClientCertSecretFieldPath = ".spec.tls.clientCertSecretRef" +) + +// nolint:gocyclo +// IndexerFuncForFieldPath returns an indexer function for a given field path. +func (m *MariaDB) IndexerFuncForFieldPath(fieldPath string) (client.IndexerFunc, error) { + switch fieldPath { + case mariadbMyCnfConfigMapFieldPath: + return func(obj client.Object) []string { + mdb, ok := obj.(*MariaDB) + if !ok { + return nil + } + if mdb.Spec.MyCnfConfigMapKeyRef != nil && mdb.Spec.MyCnfConfigMapKeyRef.LocalObjectReference.Name != "" { + return []string{mdb.Spec.MyCnfConfigMapKeyRef.LocalObjectReference.Name} + } + return nil + }, nil + case mariadbMetricsPasswordSecretFieldPath: + return func(obj client.Object) []string { + mdb, ok := obj.(*MariaDB) + if !ok { + return nil + } + if mdb.AreMetricsEnabled() && mdb.Spec.Metrics != nil && mdb.Spec.Metrics.PasswordSecretKeyRef.Name != "" { + return []string{mdb.Spec.Metrics.PasswordSecretKeyRef.Name} + } + return nil + }, nil + case mariadbTLSServerCASecretFieldPath: + return func(o client.Object) []string { + mdb, ok := o.(*MariaDB) + if !ok { + return nil + } + if mdb.IsTLSEnabled() { + return []string{mdb.TLSServerCASecretKey().Name} + } + return nil + }, nil + case mariadbTLSServerCertSecretFieldPath: + return func(o client.Object) []string { + mdb, ok := o.(*MariaDB) + if !ok { + return nil + } + if mdb.IsTLSEnabled() { + return []string{mdb.TLSServerCertSecretKey().Name} + } + return nil + }, nil + case mariadbTLSClientCASecretFieldPath: + return func(o client.Object) []string { + mdb, ok := o.(*MariaDB) + if !ok { + return nil + } + if mdb.IsTLSEnabled() { + return []string{mdb.TLSClientCASecretKey().Name} + } + return nil + }, nil + case mariadbTLSClientCertSecretFieldPath: + return func(o client.Object) []string { + mdb, ok := o.(*MariaDB) + if !ok { + return nil + } + if mdb.IsTLSEnabled() { + return []string{mdb.TLSClientCertSecretKey().Name} + } + return nil + }, nil + default: + return nil, fmt.Errorf("unsupported field path: %s", fieldPath) + } +} + +// IndexMariaDB watches and indexes external resources referred by MariaDB resources. +func IndexMariaDB(ctx context.Context, mgr manager.Manager, builder *ctrlbuilder.Builder, client client.Client) error { + watcherIndexer := watch.NewWatcherIndexer(mgr, builder, client) + + if err := watcherIndexer.Watch( + ctx, + &corev1.ConfigMap{}, + &MariaDB{}, + &MariaDBList{}, + mariadbMyCnfConfigMapFieldPath, + ctrlbuilder.WithPredicates( + predicate.PredicateWithLabel(metadata.WatchLabel), + ), + ); err != nil { + return fmt.Errorf("error watching '%s': %v", mariadbMyCnfConfigMapFieldPath, err) + } + + secretFieldPaths := []string{ + mariadbMetricsPasswordSecretFieldPath, + mariadbTLSServerCASecretFieldPath, + mariadbTLSServerCertSecretFieldPath, + mariadbTLSClientCASecretFieldPath, + mariadbTLSClientCertSecretFieldPath, + } + for _, fieldPath := range secretFieldPaths { + if err := watcherIndexer.Watch( + ctx, + &corev1.Secret{}, + &MariaDB{}, + &MariaDBList{}, + fieldPath, + ctrlbuilder.WithPredicates( + predicate.PredicateWithLabel(metadata.WatchLabel), + ), + ); err != nil { + return fmt.Errorf("error watching '%s': %v", fieldPath, err) + } + } + + return nil +} diff --git a/api/v1alpha1/mariadb_keys.go b/api/v1alpha1/mariadb_keys.go index 920cbd1a4d..a868092b12 100644 --- a/api/v1alpha1/mariadb_keys.go +++ b/api/v1alpha1/mariadb_keys.go @@ -3,7 +3,9 @@ package v1alpha1 import ( "fmt" + "github.com/mariadb-operator/mariadb-operator/pkg/pki" "k8s.io/apimachinery/pkg/types" + "k8s.io/utils/ptr" ) // RootPasswordSecretKeyRef defines the key selector for the root password Secret. @@ -52,6 +54,104 @@ func (m *MariaDB) MyCnfConfigMapKeyRef() ConfigMapKeySelector { } } +// TLSCABundleSecretKeyRef defines the key selector for the TLS Secret trust bundle +func (m *MariaDB) TLSCABundleSecretKeyRef() SecretKeySelector { + return SecretKeySelector{ + LocalObjectReference: LocalObjectReference{ + Name: fmt.Sprintf("%s-ca-bundle", m.Name), + }, + Key: pki.CACertKey, + } +} + +// TLSConfigMapKeyRef defines the key selector for the TLS ConfigMap +func (m *MariaDB) TLSConfigMapKeyRef() ConfigMapKeySelector { + return ConfigMapKeySelector{ + LocalObjectReference: LocalObjectReference{ + Name: fmt.Sprintf("%s-config-tls", m.Name), + }, + Key: "1-tls.cnf", + } +} + +// TLSServerCASecretKey defines the key for the TLS server CA. +func (m *MariaDB) TLSServerCASecretKey() types.NamespacedName { + tls := ptr.Deref(m.Spec.TLS, TLS{}) + if tls.Enabled { + if tls.ServerCASecretRef != nil { + return types.NamespacedName{ + Name: tls.ServerCASecretRef.Name, + Namespace: m.Namespace, + } + } + if tls.ServerCertIssuerRef != nil { + // Secret issued by cert-manager containing the ca.crt field. + return types.NamespacedName{ + Name: m.TLSServerCertSecretKey().Name, + Namespace: m.Namespace, + } + } + } + return types.NamespacedName{ + Name: fmt.Sprintf("%s-ca", m.Name), + Namespace: m.Namespace, + } +} + +// TLSServerCertSecretKey defines the key for the TLS server cert. +func (m *MariaDB) TLSServerCertSecretKey() types.NamespacedName { + tls := ptr.Deref(m.Spec.TLS, TLS{}) + if tls.Enabled && tls.ServerCertSecretRef != nil { + return types.NamespacedName{ + Name: tls.ServerCertSecretRef.Name, + Namespace: m.Namespace, + } + } + return types.NamespacedName{ + Name: fmt.Sprintf("%s-server-cert", m.Name), + Namespace: m.Namespace, + } +} + +// TLSClientCASecretKey defines the key for the TLS client CA. +func (m *MariaDB) TLSClientCASecretKey() types.NamespacedName { + tls := ptr.Deref(m.Spec.TLS, TLS{}) + if tls.Enabled { + if tls.ClientCASecretRef != nil { + return types.NamespacedName{ + Name: tls.ClientCASecretRef.Name, + Namespace: m.Namespace, + } + } + if tls.ClientCertIssuerRef != nil { + // Secret issued by cert-manager containing the ca.crt field. + return types.NamespacedName{ + Name: m.TLSClientCertSecretKey().Name, + Namespace: m.Namespace, + } + } + } + return types.NamespacedName{ + Name: fmt.Sprintf("%s-ca", m.Name), + Namespace: m.Namespace, + } +} + +// TLSClientCertSecretKey defines the key for the TLS client cert. +func (m *MariaDB) TLSClientCertSecretKey() types.NamespacedName { + tls := ptr.Deref(m.Spec.TLS, TLS{}) + if tls.Enabled && tls.ClientCertSecretRef != nil { + return types.NamespacedName{ + Name: tls.ClientCertSecretRef.Name, + Namespace: m.Namespace, + } + } + return types.NamespacedName{ + Name: fmt.Sprintf("%s-client-cert", m.Name), + Namespace: m.Namespace, + } +} + // RestoreKey defines the key for the Restore resource used to bootstrap. func (m *MariaDB) RestoreKey() types.NamespacedName { return types.NamespacedName{ diff --git a/api/v1alpha1/mariadb_types.go b/api/v1alpha1/mariadb_types.go index 6382533b6c..902cb16412 100644 --- a/api/v1alpha1/mariadb_types.go +++ b/api/v1alpha1/mariadb_types.go @@ -4,8 +4,12 @@ import ( "errors" "fmt" + cmmeta "github.com/cert-manager/cert-manager/pkg/apis/meta/v1" + "github.com/go-logr/logr" + "github.com/mariadb-operator/mariadb-operator/pkg/discovery" "github.com/mariadb-operator/mariadb-operator/pkg/environment" "github.com/mariadb-operator/mariadb-operator/pkg/statefulset" + "github.com/mariadb-operator/mariadb-operator/pkg/version" appsv1 "k8s.io/api/apps/v1" corev1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/api/meta" @@ -224,6 +228,10 @@ type MariaDBMaxScaleSpec struct { // +optional // +operator-sdk:csv:customresourcedefinitions:type=spec Metrics *MaxScaleMetrics `json:"metrics,omitempty"` + // TLS defines the PKI to be used with MaxScale. + // +optional + // +operator-sdk:csv:customresourcedefinitions:type=spec + TLS *MaxScaleTLS `json:"tls,omitempty"` // Connection provides a template to define the Connection for MaxScale. // +optional // +operator-sdk:csv:customresourcedefinitions:type=spec @@ -314,6 +322,66 @@ func (u *UpdateStrategy) SetDefaults() { } } +// TLS defines the PKI to be used with MariaDB. +type TLS struct { + // Enabled is a flag to enable TLS. + // +optional + // +operator-sdk:csv:customresourcedefinitions:type=spec,xDescriptors={"urn:alm:descriptor:com.tectonic.ui:booleanSwitch"} + Enabled bool `json:"enabled"` + // ServerCASecretRef is a reference to a Secret containing the server certificate authority keypair. It is used to establish trust and issue server certificates. + // One of: + // - Secret containing both the 'ca.crt' and 'ca.key' keys. This allows you to bring your own CA to Kubernetes to issue certificates. + // - Secret containing only the 'ca.crt' in order to establish trust. In this case, either serverCertSecretRef or serverCertIssuerRef must be provided. + // If not provided, a self-signed CA will be provisioned to issue the server certificate. + // +optional + // +operator-sdk:csv:customresourcedefinitions:type=spec,xDescriptors={"urn:alm:descriptor:com.tectonic.ui:advanced"} + ServerCASecretRef *LocalObjectReference `json:"serverCASecretRef,omitempty"` + // ServerCertSecretRef is a reference to a TLS Secret containing the server certificate. + // It is mutually exclusive with serverCertIssuerRef. + // +optional + // +operator-sdk:csv:customresourcedefinitions:type=spec,xDescriptors={"urn:alm:descriptor:com.tectonic.ui:advanced"} + ServerCertSecretRef *LocalObjectReference `json:"serverCertSecretRef,omitempty"` + // ServerCertIssuerRef is a reference to a cert-manager issuer object used to issue the server certificate. cert-manager must be installed previously in the cluster. + // It is mutually exclusive with serverCertSecretRef. + // By default, the Secret field 'ca.crt' provisioned by cert-manager will be added to the trust chain. A custom trust bundle may be specified via serverCASecretRef. + // +optional + // +operator-sdk:csv:customresourcedefinitions:type=spec,xDescriptors={"urn:alm:descriptor:com.tectonic.ui:advanced"} + ServerCertIssuerRef *cmmeta.ObjectReference `json:"serverCertIssuerRef,omitempty"` + // ClientCASecretRef is a reference to a Secret containing the client certificate authority keypair. It is used to establish trust and issue client certificates. + // One of: + // - Secret containing both the 'ca.crt' and 'ca.key' keys. This allows you to bring your own CA to Kubernetes to issue certificates. + // - Secret containing only the 'ca.crt' in order to establish trust. In this case, either clientCertSecretRef or clientCertIssuerRef fields must be provided. + // If not provided, a self-signed CA will be provisioned to issue the client certificate. + // +optional + // +operator-sdk:csv:customresourcedefinitions:type=spec,xDescriptors={"urn:alm:descriptor:com.tectonic.ui:advanced"} + ClientCASecretRef *LocalObjectReference `json:"clientCASecretRef,omitempty"` + // ClientCertSecretRef is a reference to a TLS Secret containing the client certificate. + // It is mutually exclusive with clientCertIssuerRef. + // +optional + // +operator-sdk:csv:customresourcedefinitions:type=spec,xDescriptors={"urn:alm:descriptor:com.tectonic.ui:advanced"} + ClientCertSecretRef *LocalObjectReference `json:"clientCertSecretRef,omitempty"` + // ClientCertIssuerRef is a reference to a cert-manager issuer object used to issue the client certificate. cert-manager must be installed previously in the cluster. + // It is mutually exclusive with clientCertSecretRef. + // By default, the Secret field 'ca.crt' provisioned by cert-manager will be added to the trust chain. A custom trust bundle may be specified via clientCASecretRef. + // +optional + // +operator-sdk:csv:customresourcedefinitions:type=spec,xDescriptors={"urn:alm:descriptor:com.tectonic.ui:advanced"} + ClientCertIssuerRef *cmmeta.ObjectReference `json:"clientCertIssuerRef,omitempty"` + // GaleraServerSSLMode defines the server SSL mode for a Galera Enterprise cluster. + // This field is only supported and applicable for Galera Enterprise >= 10.6 instances. + // Refer to the MariaDB Enterprise docs for more detail: https://mariadb.com/docs/server/security/galera/#WSREP_TLS_Modes + // +optional + // +kubebuilder:validation:Enum=PROVIDER;SERVER;SERVER_X509 + // +operator-sdk:csv:customresourcedefinitions:type=spec,xDescriptors={"urn:alm:descriptor:com.tectonic.ui:advanced"} + GaleraServerSSLMode *string `json:"galeraServerSSLMode,omitempty"` + // GaleraClientSSLMode defines the client SSL mode for a Galera Enterprise cluster. + // This field is only supported and applicable for Galera Enterprise >= 10.6 instances. + // Refer to the MariaDB Enterprise docs for more detail: https://mariadb.com/docs/server/security/galera/#SST_TLS_Modes + // +optional + // +kubebuilder:validation:Enum=DISABLED;REQUIRED;VERIFY_CA;VERIFY_IDENTITY + // +operator-sdk:csv:customresourcedefinitions:type=spec,xDescriptors={"urn:alm:descriptor:com.tectonic.ui:advanced"} + GaleraClientSSLMode *string `json:"galeraClientSSLMode,omitempty"` +} + // MariaDBSpec defines the desired state of MariaDB type MariaDBSpec struct { // ContainerTemplate defines templates to configure Container objects. @@ -397,6 +465,10 @@ type MariaDBSpec struct { // +optional // +operator-sdk:csv:customresourcedefinitions:type=spec Metrics *MariadbMetrics `json:"metrics,omitempty"` + // TLS defines the PKI to be used with MariaDB. + // +optional + // +operator-sdk:csv:customresourcedefinitions:type=spec + TLS *TLS `json:"tls,omitempty"` // Replication configures high availability via replication. This feature is still in alpha, use Galera if you are looking for a more production-ready HA. // +optional // +operator-sdk:csv:customresourcedefinitions:type=spec @@ -475,6 +547,22 @@ type MariaDBSpec struct { SecondaryConnection *ConnectionTemplate `json:"secondaryConnection,omitempty" webhook:"inmutable"` } +// MariaDBTLSStatus aggregates the status of the certificates used by the MariaDB instance. +type MariaDBTLSStatus struct { + // CABundle is the status of the Certificate Authority bundle. + // +optional + // +operator-sdk:csv:customresourcedefinitions:type=status + CABundle []CertificateStatus `json:"caBundle,omitempty"` + // ServerCert is the status of the server certificate. + // +optional + // +operator-sdk:csv:customresourcedefinitions:type=status + ServerCert *CertificateStatus `json:"serverCert,omitempty"` + // ClientCert is the status of the client certificate. + // +optional + // +operator-sdk:csv:customresourcedefinitions:type=status + ClientCert *CertificateStatus `json:"clientCert,omitempty"` +} + // MariaDBStatus defines the observed state of MariaDB type MariaDBStatus struct { // Conditions for the Mariadb object. @@ -500,6 +588,16 @@ type MariaDBStatus struct { // +optional // +operator-sdk:csv:customresourcedefinitions:type=status ReplicationStatus ReplicationStatus `json:"replicationStatus,omitempty"` + // DefaultVersion is the MariaDB version used by the operator when it cannot infer the version + // from spec.image. This can happen if the image uses a digest (e.g. sha256) instead + // of a version tag. + // +optional + // +operator-sdk:csv:customresourcedefinitions:type=status + DefaultVersion string `json:"defaultVersion,omitempty"` + // TLS aggregates the status of the certificates used by the MariaDB instance. + // +optional + // +operator-sdk:csv:customresourcedefinitions:type=status + TLS *MariaDBTLSStatus `json:"tls,omitempty"` } // SetCondition sets a status condition to MariaDB @@ -653,6 +751,36 @@ func (m *MariaDB) IsEphemeralStorageEnabled() bool { return ptr.Deref(m.Spec.Storage.Ephemeral, false) } +// IsTLSEnabled indicates whether the MariaDB instance has TLS enabled +func (m *MariaDB) IsTLSEnabled() bool { + return ptr.Deref(m.Spec.TLS, TLS{}).Enabled +} + +// IsGaleraEnterpriseTLSAvailable indicates whether Galera enteprise TLS is available +func (m *MariaDB) IsGaleraEnterpriseTLSAvailable(discovery *discovery.Discovery, defaultMariadbVersion string, + logger logr.Logger) (bool, error) { + if !m.IsGaleraEnabled() || !discovery.IsEnterprise() || !m.IsTLSEnabled() { + return false, nil + } + + vOpts := []version.Option{ + version.WithLogger(logger), + } + if defaultMariadbVersion != "" { + vOpts = append(vOpts, version.WithDefaultVersion(defaultMariadbVersion)) + } + version, err := version.NewVersion(m.Spec.Image, vOpts...) + if err != nil { + return false, fmt.Errorf("error parsing version: %v", err) + } + + isCompatibleVersion, err := version.GreaterThanOrEqual("10.6") + if err != nil { + return false, fmt.Errorf("error comparing version: %v", err) + } + return isCompatibleVersion, nil +} + // IsReady indicates whether the MariaDB instance is ready func (m *MariaDB) IsReady() bool { return meta.IsStatusConditionTrue(m.Status.Conditions, ConditionTypeReady) @@ -705,41 +833,22 @@ func (m *MariaDB) IsSuspended() bool { return m.Spec.Suspend } -const ( - // MariadbMyCnfConfigMapFieldPath is the path related to the my.cnf ConfigMap field. - MariadbMyCnfConfigMapFieldPath = ".spec.myCnfConfigMapKeyRef.name" - // MariadbMetricsPasswordSecretFieldPath is the path related to the metrics password Secret field. - MariadbMetricsPasswordSecretFieldPath = ".spec.metrics.passwordSecretKeyRef" -) - -// IndexerFuncForFieldPath returns an indexer function for a given field path. -func (m *MariaDB) IndexerFuncForFieldPath(fieldPath string) (client.IndexerFunc, error) { - switch fieldPath { - case MariadbMyCnfConfigMapFieldPath: - return func(obj client.Object) []string { - mdb, ok := obj.(*MariaDB) - if !ok { - return nil - } - if mdb.Spec.MyCnfConfigMapKeyRef != nil && mdb.Spec.MyCnfConfigMapKeyRef.LocalObjectReference.Name != "" { - return []string{mdb.Spec.MyCnfConfigMapKeyRef.LocalObjectReference.Name} - } - return nil - }, nil - case MariadbMetricsPasswordSecretFieldPath: - return func(obj client.Object) []string { - mdb, ok := obj.(*MariaDB) - if !ok { - return nil - } - if mdb.AreMetricsEnabled() && mdb.Spec.Metrics != nil && mdb.Spec.Metrics.PasswordSecretKeyRef.Name != "" { - return []string{mdb.Spec.Metrics.PasswordSecretKeyRef.Name} - } - return nil - }, nil - default: - return nil, fmt.Errorf("unsupported field path: %s", fieldPath) +// ServerDNSNames are the Service DNS names used by server TLS certificates. +func (m *MariaDB) TLSServerDNSNames() []string { + var names []string + names = append(names, statefulset.ServiceNameVariants(m.ObjectMeta, m.Name)...) + if m.IsHAEnabled() { + names = append(names, statefulset.HeadlessServiceNameVariants(m.ObjectMeta, "*", m.InternalServiceKey().Name)...) + names = append(names, statefulset.ServiceNameVariants(m.ObjectMeta, m.PrimaryServiceKey().Name)...) + names = append(names, statefulset.ServiceNameVariants(m.ObjectMeta, m.SecondaryServiceKey().Name)...) } + names = append(names, "localhost") + return names +} + +// TLSClientNames are the names used by client TLS certificates. +func (m *MariaDB) TLSClientNames() []string { + return []string{fmt.Sprintf("%s-client", m.Name)} } // +kubebuilder:object:root=true diff --git a/api/v1alpha1/mariadb_types_test.go b/api/v1alpha1/mariadb_types_test.go index 4f3974a0c8..7740c44920 100644 --- a/api/v1alpha1/mariadb_types_test.go +++ b/api/v1alpha1/mariadb_types_test.go @@ -1,6 +1,8 @@ package v1alpha1 import ( + "github.com/go-logr/logr" + "github.com/mariadb-operator/mariadb-operator/pkg/discovery" "github.com/mariadb-operator/mariadb-operator/pkg/environment" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" @@ -1346,6 +1348,140 @@ var _ = Describe("MariaDB types", func() { ), ) + DescribeTable( + "Is Galera enterprise TLS enabled?", + func(mdb *MariaDB, isEnterprise bool, defaultMariadbVersion string, wantIsEnabled, wantErr bool) { + discovery, err := discovery.NewFakeDiscovery(isEnterprise) + Expect(err).ToNot(HaveOccurred()) + logger := logr.Discard() + + isEnabled, err := mdb.IsGaleraEnterpriseTLSAvailable(discovery, defaultMariadbVersion, logger) + if wantErr { + Expect(err).To(HaveOccurred()) + } else { + Expect(err).ToNot(HaveOccurred()) + } + Expect(isEnabled).To(Equal(wantIsEnabled)) + }, + Entry( + "Standalone", + &MariaDB{ + Spec: MariaDBSpec{ + Image: "mariadb:11.4.3", + }, + }, + false, + "", + false, + false, + ), + Entry( + "Galera", + &MariaDB{ + Spec: MariaDBSpec{ + Image: "mariadb:11.4.3", + Galera: &Galera{ + Enabled: true, + }, + }, + }, + false, + "", + false, + false, + ), + Entry( + "Galera TLS", + &MariaDB{ + Spec: MariaDBSpec{ + Image: "mariadb:11.4.3", + Galera: &Galera{ + Enabled: true, + }, + TLS: &TLS{ + Enabled: true, + }, + }, + }, + false, + "", + false, + false, + ), + Entry( + "Enabled", + &MariaDB{ + Spec: MariaDBSpec{ + Image: "docker.mariadb.com/enterprise-server:10.6", + Galera: &Galera{ + Enabled: true, + }, + TLS: &TLS{ + Enabled: true, + }, + }, + }, + true, + "", + true, + false, + ), + Entry( + "Unsupported version", + &MariaDB{ + Spec: MariaDBSpec{ + Image: "docker.mariadb.com/enterprise-server:10.5", + Galera: &Galera{ + Enabled: true, + }, + TLS: &TLS{ + Enabled: true, + }, + }, + }, + true, + "", + false, + false, + ), + Entry( + "Invalid image", + &MariaDB{ + Spec: MariaDBSpec{ + Image: "docker.mariadb.com/enterprise-server@sha256:3f48454b6a33e094af6d23ced54645ec0533cb11854d07738920852ca48e390d", + Galera: &Galera{ + Enabled: true, + }, + TLS: &TLS{ + Enabled: true, + }, + }, + }, + true, + "", + false, + true, + ), + Entry( + "Invalid image with default", + &MariaDB{ + Spec: MariaDBSpec{ + Image: "docker.mariadb.com/enterprise-server:@sha256:3f48454b6a33e094af6d23ced54645ec0533cb11854d07738920852ca48e390d", + Galera: &Galera{ + Enabled: true, + }, + TLS: &TLS{ + Enabled: true, + }, + }, + }, + true, + "10.6", + true, + false, + ), + ) + DescribeTable( "Get size", func(mdb *MariaDB, wantSize *resource.Quantity) { diff --git a/api/v1alpha1/mariadb_webhook.go b/api/v1alpha1/mariadb_webhook.go index 8db47e8865..3b98785866 100644 --- a/api/v1alpha1/mariadb_webhook.go +++ b/api/v1alpha1/mariadb_webhook.go @@ -51,6 +51,7 @@ func (r *MariaDB) ValidateCreate() (admission.Warnings, error) { r.validateStorage, r.validateRootPassword, r.validateMaxScale, + r.validateTLS, } for _, fn := range validateFns { if err := fn(); err != nil { @@ -75,6 +76,7 @@ func (r *MariaDB) ValidateUpdate(old runtime.Object) (admission.Warnings, error) r.validatePodDisruptionBudget, r.validateStorage, r.validateRootPassword, + r.validateTLS, } for _, fn := range validateFns { if err := fn(); err != nil { @@ -297,3 +299,36 @@ func (r *MariaDB) validateRootPassword() error { } return nil } + +func (r *MariaDB) validateTLS() error { + tls := ptr.Deref(r.Spec.TLS, TLS{}) + if !tls.Enabled { + return nil + } + validationItems := []tlsValidationItem{ + { + tlsValue: r.Spec.TLS, + caSecretRef: tls.ServerCASecretRef, + caFieldPath: "spec.tls.serverCASecretRef", + certSecretRef: tls.ServerCertSecretRef, + certFieldPath: "spec.tls.serverCertSecretRef", + certIssuerRef: tls.ServerCertIssuerRef, + certIssuerFieldPath: "spec.tls.serverCertIssuerRef", + }, + { + tlsValue: r.Spec.TLS, + caSecretRef: tls.ClientCASecretRef, + caFieldPath: "spec.tls.clientCASecretRef", + certSecretRef: tls.ClientCertSecretRef, + certFieldPath: "spec.tls.clientCertSecretRef", + certIssuerRef: tls.ClientCertIssuerRef, + certIssuerFieldPath: "spec.tls.clientCertIssuerRef", + }, + } + for _, item := range validationItems { + if err := validateTLSCert(&item); err != nil { + return err + } + } + return nil +} diff --git a/api/v1alpha1/mariadb_webhook_test.go b/api/v1alpha1/mariadb_webhook_test.go index 2e2de4a99a..2b869413b8 100644 --- a/api/v1alpha1/mariadb_webhook_test.go +++ b/api/v1alpha1/mariadb_webhook_test.go @@ -518,6 +518,61 @@ var _ = Describe("MariaDB webhook", func() { }, false, ), + Entry( + "Invalid TLS", + &MariaDB{ + ObjectMeta: meta, + Spec: MariaDBSpec{ + RootPasswordSecretKeyRef: GeneratedSecretKeyRef{ + SecretKeySelector: SecretKeySelector{ + LocalObjectReference: LocalObjectReference{ + Name: "secret", + }, + Key: "root-password", + }, + }, + Storage: Storage{ + Size: ptr.To(resource.MustParse("100Mi")), + }, + TLS: &TLS{ + Enabled: true, + ServerCertSecretRef: &LocalObjectReference{ + Name: "server-cert", + }, + }, + }, + }, + true, + ), + Entry( + "Valid TLS", + &MariaDB{ + ObjectMeta: meta, + Spec: MariaDBSpec{ + RootPasswordSecretKeyRef: GeneratedSecretKeyRef{ + SecretKeySelector: SecretKeySelector{ + LocalObjectReference: LocalObjectReference{ + Name: "secret", + }, + Key: "root-password", + }, + }, + Storage: Storage{ + Size: ptr.To(resource.MustParse("100Mi")), + }, + TLS: &TLS{ + Enabled: true, + ServerCASecretRef: &LocalObjectReference{ + Name: "server-ca", + }, + ServerCertSecretRef: &LocalObjectReference{ + Name: "server-cert", + }, + }, + }, + }, + false, + ), ) It("Should default replication", func() { @@ -804,6 +859,33 @@ var _ = Describe("MariaDB webhook", func() { }, false, ), + Entry( + "Updating to invalid TLS", + func(mdb *MariaDB) { + mdb.Spec.TLS = &TLS{ + Enabled: true, + ServerCertSecretRef: &LocalObjectReference{ + Name: "server-cert", + }, + } + }, + true, + ), + Entry( + "Updating to valid TLS", + func(mdb *MariaDB) { + mdb.Spec.TLS = &TLS{ + Enabled: true, + ServerCASecretRef: &LocalObjectReference{ + Name: "server-ca", + }, + ServerCertSecretRef: &LocalObjectReference{ + Name: "server-cert", + }, + } + }, + false, + ), ) }) diff --git a/api/v1alpha1/maxscale_indexes.go b/api/v1alpha1/maxscale_indexes.go new file mode 100644 index 0000000000..9ab59549bb --- /dev/null +++ b/api/v1alpha1/maxscale_indexes.go @@ -0,0 +1,142 @@ +package v1alpha1 + +import ( + "context" + "fmt" + + "github.com/mariadb-operator/mariadb-operator/pkg/metadata" + "github.com/mariadb-operator/mariadb-operator/pkg/predicate" + "github.com/mariadb-operator/mariadb-operator/pkg/watch" + corev1 "k8s.io/api/core/v1" + ctrlbuilder "sigs.k8s.io/controller-runtime/pkg/builder" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/manager" +) + +const ( + maxScaleMetricsPasswordSecretFieldPath = ".spec.auth.metricsPasswordSecretKeyRef.name" + + maxscaleTLSAdminCASecretFieldPath = ".spec.tls.adminCASecretRef" + maxscaleTLSAdminCertSecretFieldPath = ".spec.tls.adminCertSecretRef" + maxscaleTLSListenerCASecretFieldPath = ".spec.tls.listenerCASecretRef" + maxscaleTLSListenerCertSecretFieldPath = ".spec.tls.listenerCertSecretRef" + maxscaleTLSServerCASecretFieldPath = ".spec.tls.serverCASecretRef" + maxscaleTLSServerCertSecretFieldPath = ".spec.tls.serverCertSecretRef" +) + +// nolint:gocyclo +// IndexerFuncForFieldPath returns an indexer function for a given field path. +func (m *MaxScale) IndexerFuncForFieldPath(fieldPath string) (client.IndexerFunc, error) { + switch fieldPath { + case maxScaleMetricsPasswordSecretFieldPath: + return func(obj client.Object) []string { + maxscale, ok := obj.(*MaxScale) + if !ok { + return nil + } + if maxscale.AreMetricsEnabled() && maxscale.Spec.Auth.MetricsPasswordSecretKeyRef.Name != "" { + return []string{maxscale.Spec.Auth.MetricsPasswordSecretKeyRef.Name} + } + return nil + }, nil + case maxscaleTLSAdminCASecretFieldPath: + return func(obj client.Object) []string { + maxscale, ok := obj.(*MaxScale) + if !ok { + return nil + } + if maxscale.IsTLSEnabled() { + return []string{maxscale.TLSAdminCASecretKey().Name} + } + return nil + }, nil + case maxscaleTLSAdminCertSecretFieldPath: + return func(obj client.Object) []string { + maxscale, ok := obj.(*MaxScale) + if !ok { + return nil + } + if maxscale.IsTLSEnabled() { + return []string{maxscale.TLSAdminCertSecretKey().Name} + } + return nil + }, nil + case maxscaleTLSListenerCASecretFieldPath: + return func(obj client.Object) []string { + maxscale, ok := obj.(*MaxScale) + if !ok { + return nil + } + if maxscale.IsTLSEnabled() { + return []string{maxscale.TLSListenerCASecretKey().Name} + } + return nil + }, nil + case maxscaleTLSListenerCertSecretFieldPath: + return func(obj client.Object) []string { + maxscale, ok := obj.(*MaxScale) + if !ok { + return nil + } + if maxscale.IsTLSEnabled() { + return []string{maxscale.TLSListenerCertSecretKey().Name} + } + return nil + }, nil + case maxscaleTLSServerCASecretFieldPath: + return func(obj client.Object) []string { + maxscale, ok := obj.(*MaxScale) + if !ok { + return nil + } + if maxscale.IsTLSEnabled() { + return []string{maxscale.TLSServerCASecretKey().Name} + } + return nil + }, nil + case maxscaleTLSServerCertSecretFieldPath: + return func(obj client.Object) []string { + maxscale, ok := obj.(*MaxScale) + if !ok { + return nil + } + if maxscale.IsTLSEnabled() { + return []string{maxscale.TLSServerCertSecretKey().Name} + } + return nil + }, nil + default: + return nil, fmt.Errorf("unsupported field path: %s", fieldPath) + } +} + +// IndexMaxScale watches and indexes external resources referred by MaxScale resources. +func IndexMaxScale(ctx context.Context, mgr manager.Manager, builder *ctrlbuilder.Builder, client client.Client) error { + watcherIndexer := watch.NewWatcherIndexer(mgr, builder, client) + + secretFieldPaths := []string{ + maxScaleMetricsPasswordSecretFieldPath, + maxscaleTLSAdminCASecretFieldPath, + maxscaleTLSAdminCertSecretFieldPath, + maxscaleTLSListenerCASecretFieldPath, + maxscaleTLSListenerCertSecretFieldPath, + maxscaleTLSServerCASecretFieldPath, + maxscaleTLSServerCertSecretFieldPath, + } + for _, fieldPath := range secretFieldPaths { + if err := watcherIndexer.Watch( + ctx, + &corev1.Secret{}, + &MaxScale{}, + &MaxScaleList{}, + fieldPath, + ctrlbuilder.WithPredicates( + predicate.PredicateWithLabel(metadata.WatchLabel), + ), + ); err != nil { + return fmt.Errorf("error watching '%s': %v", fieldPath, err) + } + } + + return nil +} diff --git a/api/v1alpha1/maxscale_keys.go b/api/v1alpha1/maxscale_keys.go index a65e1a0aac..789c72c691 100644 --- a/api/v1alpha1/maxscale_keys.go +++ b/api/v1alpha1/maxscale_keys.go @@ -3,7 +3,9 @@ package v1alpha1 import ( "fmt" + "github.com/mariadb-operator/mariadb-operator/pkg/pki" "k8s.io/apimachinery/pkg/types" + "k8s.io/utils/ptr" ) // InternalServiceKey defines the key for the internal headless Service @@ -90,6 +92,124 @@ func (m *MaxScale) ConfigSecretKeyRef() GeneratedSecretKeyRef { } } +// TLSCABundleSecretKeyRef defines the key selector for the TLS Secret trust bundle +func (m *MaxScale) TLSCABundleSecretKeyRef() SecretKeySelector { + return SecretKeySelector{ + LocalObjectReference: LocalObjectReference{ + Name: fmt.Sprintf("%s-ca-bundle", m.Name), + }, + Key: pki.CACertKey, + } +} + +// TLSServerCASecretKey defines the key for the TLS admin CA. +func (m *MaxScale) TLSAdminCASecretKey() types.NamespacedName { + tls := ptr.Deref(m.Spec.TLS, MaxScaleTLS{}) + if tls.Enabled { + if tls.AdminCASecretRef != nil { + return types.NamespacedName{ + Name: tls.AdminCASecretRef.Name, + Namespace: m.Namespace, + } + } + if tls.AdminCertIssuerRef != nil { + // Secret issued by cert-manager containing the ca.crt field. + return types.NamespacedName{ + Name: m.TLSAdminCertSecretKey().Name, + Namespace: m.Namespace, + } + } + } + return types.NamespacedName{ + Name: fmt.Sprintf("%s-ca", m.Name), + Namespace: m.Namespace, + } +} + +// TLSAdminCertSecretKey defines the key for the TLS admin cert. +func (m *MaxScale) TLSAdminCertSecretKey() types.NamespacedName { + tls := ptr.Deref(m.Spec.TLS, MaxScaleTLS{}) + if tls.Enabled && tls.AdminCertSecretRef != nil { + return types.NamespacedName{ + Name: tls.AdminCertSecretRef.Name, + Namespace: m.Namespace, + } + } + return types.NamespacedName{ + Name: fmt.Sprintf("%s-admin-cert", m.Name), + Namespace: m.Namespace, + } +} + +// TLSListenerCASecretKey defines the key for the TLS listener CA. +func (m *MaxScale) TLSListenerCASecretKey() types.NamespacedName { + tls := ptr.Deref(m.Spec.TLS, MaxScaleTLS{}) + if tls.Enabled { + if tls.ListenerCASecretRef != nil { + return types.NamespacedName{ + Name: tls.ListenerCASecretRef.Name, + Namespace: m.Namespace, + } + } + if tls.ListenerCertIssuerRef != nil { + // Secret issued by cert-manager containing the ca.crt field. + return types.NamespacedName{ + Name: m.TLSListenerCertSecretKey().Name, + Namespace: m.Namespace, + } + } + } + return types.NamespacedName{ + Name: fmt.Sprintf("%s-ca", m.Name), + Namespace: m.Namespace, + } +} + +// TLSListenerCertSecretKey defines the key for the TLS listener cert. +func (m *MaxScale) TLSListenerCertSecretKey() types.NamespacedName { + tls := ptr.Deref(m.Spec.TLS, MaxScaleTLS{}) + if tls.Enabled && tls.ListenerCertSecretRef != nil { + return types.NamespacedName{ + Name: tls.ListenerCertSecretRef.Name, + Namespace: m.Namespace, + } + } + return types.NamespacedName{ + Name: fmt.Sprintf("%s-listener-cert", m.Name), + Namespace: m.Namespace, + } +} + +// TLSServerCASecretKey defines the key for the TLS MariaDB server CA. +func (m *MaxScale) TLSServerCASecretKey() types.NamespacedName { + tls := ptr.Deref(m.Spec.TLS, MaxScaleTLS{}) + if tls.Enabled && tls.ServerCASecretRef != nil { + return types.NamespacedName{ + Name: tls.ServerCASecretRef.Name, + Namespace: m.Namespace, + } + } + return types.NamespacedName{ + Name: fmt.Sprintf("%s-ca", m.Name), + Namespace: m.Namespace, + } +} + +// TLSServerCertSecretKey defines the key for the TLS MariaDB server cert. +func (m *MaxScale) TLSServerCertSecretKey() types.NamespacedName { + tls := ptr.Deref(m.Spec.TLS, MaxScaleTLS{}) + if tls.Enabled && tls.ServerCertSecretRef != nil { + return types.NamespacedName{ + Name: tls.ServerCertSecretRef.Name, + Namespace: m.Namespace, + } + } + return types.NamespacedName{ + Name: fmt.Sprintf("%s-server-cert", m.Name), + Namespace: m.Namespace, + } +} + // AuthClientUserKey defines the key for the client User func (m *MaxScale) AuthClientUserKey() LocalObjectReference { return LocalObjectReference{ diff --git a/api/v1alpha1/maxscale_types.go b/api/v1alpha1/maxscale_types.go index 5b08a90fc1..37d0c4996a 100644 --- a/api/v1alpha1/maxscale_types.go +++ b/api/v1alpha1/maxscale_types.go @@ -5,6 +5,7 @@ import ( "fmt" "time" + cmmeta "github.com/cert-manager/cert-manager/pkg/apis/meta/v1" ds "github.com/mariadb-operator/mariadb-operator/pkg/datastructures" "github.com/mariadb-operator/mariadb-operator/pkg/environment" mxsstate "github.com/mariadb-operator/mariadb-operator/pkg/maxscale/state" @@ -457,6 +458,94 @@ func (m *MaxScaleAuth) SetDefaults(mxs *MaxScale) { } } +// TLS defines the PKI to be used with MaxScale. +type MaxScaleTLS struct { + // Enabled is a flag to enable TLS. + // +optional + // +operator-sdk:csv:customresourcedefinitions:type=spec,xDescriptors={"urn:alm:descriptor:com.tectonic.ui:booleanSwitch"} + Enabled bool `json:"enabled"` + // AdminCASecretRef is a reference to a Secret containing the admin certificate authority keypair. It is used to establish trust and issue certificates for the MaxScale's administrative REST API and GUI. + // One of: + // - Secret containing both the 'ca.crt' and 'ca.key' keys. This allows you to bring your own CA to Kubernetes to issue certificates. + // - Secret containing only the 'ca.crt' in order to establish trust. In this case, either adminCertSecretRef or adminCertIssuerRef fields must be provided. + // If not provided, a self-signed CA will be provisioned to issue the server certificate. + // +optional + // +operator-sdk:csv:customresourcedefinitions:type=spec,xDescriptors={"urn:alm:descriptor:com.tectonic.ui:advanced"} + AdminCASecretRef *LocalObjectReference `json:"adminCASecretRef,omitempty"` + // AdminCertSecretRef is a reference to a TLS Secret used by the MaxScale's administrative REST API and GUI. + // +optional + // +operator-sdk:csv:customresourcedefinitions:type=spec,xDescriptors={"urn:alm:descriptor:com.tectonic.ui:advanced"} + AdminCertSecretRef *LocalObjectReference `json:"adminCertSecretRef,omitempty"` + // AdminCertIssuerRef is a reference to a cert-manager issuer object used to issue the MaxScale's administrative REST API and GUI certificate. cert-manager must be installed previously in the cluster. + // It is mutually exclusive with adminCertSecretRef. + // By default, the Secret field 'ca.crt' provisioned by cert-manager will be added to the trust chain. A custom trust bundle may be specified via adminCASecretRef. + // +optional + // +operator-sdk:csv:customresourcedefinitions:type=spec,xDescriptors={"urn:alm:descriptor:com.tectonic.ui:advanced"} + AdminCertIssuerRef *cmmeta.ObjectReference `json:"adminCertIssuerRef,omitempty"` + // ListenerCASecretRef is a reference to a Secret containing the listener certificate authority keypair. It is used to establish trust and issue certificates for the MaxScale's listeners. + // One of: + // - Secret containing both the 'ca.crt' and 'ca.key' keys. This allows you to bring your own CA to Kubernetes to issue certificates. + // - Secret containing only the 'ca.crt' in order to establish trust. In this case, either listenerCertSecretRef or listenerCertIssuerRef fields must be provided. + // If not provided, a self-signed CA will be provisioned to issue the listener certificate. + // +optional + // +operator-sdk:csv:customresourcedefinitions:type=spec,xDescriptors={"urn:alm:descriptor:com.tectonic.ui:advanced"} + ListenerCASecretRef *LocalObjectReference `json:"listenerCASecretRef,omitempty"` + // ListenerCertSecretRef is a reference to a TLS Secret used by the MaxScale's listeners. + // +optional + // +operator-sdk:csv:customresourcedefinitions:type=spec,xDescriptors={"urn:alm:descriptor:com.tectonic.ui:advanced"} + ListenerCertSecretRef *LocalObjectReference `json:"listenerCertSecretRef,omitempty"` + // ListenerCertIssuerRef is a reference to a cert-manager issuer object used to issue the MaxScale's listeners certificate. cert-manager must be installed previously in the cluster. + // It is mutually exclusive with listenerCertSecretRef. + // By default, the Secret field 'ca.crt' provisioned by cert-manager will be added to the trust chain. A custom trust bundle may be specified via listenerCASecretRef. + // +optional + // +operator-sdk:csv:customresourcedefinitions:type=spec,xDescriptors={"urn:alm:descriptor:com.tectonic.ui:advanced"} + ListenerCertIssuerRef *cmmeta.ObjectReference `json:"listenerCertIssuerRef,omitempty"` + // ServerCASecretRef is a reference to a Secret containing the MariaDB server CA certificates. It is used to establish trust with MariaDB servers. + // The Secret should contain a 'ca.crt' key in order to establish trust. + // If not provided, and the reference to a MariaDB resource is set (mariaDbRef), it will be defaulted to the referred MariaDB CA bundle. + // +optional + // +operator-sdk:csv:customresourcedefinitions:type=spec,xDescriptors={"urn:alm:descriptor:com.tectonic.ui:advanced"} + ServerCASecretRef *LocalObjectReference `json:"serverCASecretRef,omitempty"` + // ServerCertSecretRef is a reference to a TLS Secret used by MaxScale to connect to the MariaDB servers. + // If not provided, and the reference to a MariaDB resource is set (mariaDbRef), it will be defaulted to the referred MariaDB client certificate (clientCertSecretRef). + // +optional + // +operator-sdk:csv:customresourcedefinitions:type=spec,xDescriptors={"urn:alm:descriptor:com.tectonic.ui:advanced"} + ServerCertSecretRef *LocalObjectReference `json:"serverCertSecretRef,omitempty"` + // VerifyPeerCertificate specifies whether the peer certificate's signature should be validated against the CA. It is enabled by default. + // +optional + // +operator-sdk:csv:customresourcedefinitions:type=spec,xDescriptors={"urn:alm:descriptor:com.tectonic.ui:booleanSwitch"} + VerifyPeerCertificate *bool `json:"verifyPeerCertificate,omitempty"` + // VerifyPeerHost specifies whether the peer certificate's SANs should match the peer host. It is disabled by default. + // +optional + // +operator-sdk:csv:customresourcedefinitions:type=spec,xDescriptors={"urn:alm:descriptor:com.tectonic.ui:booleanSwitch"} + VerifyPeerHost *bool `json:"verifyPeerHost,omitempty"` + // ReplicationSSLEnabled specifies whether the replication SSL is enabled. If enabled, the SSL options will be added to the server configuration. + // This field is automatically set when a reference to a MariaDB via the 'mariaDbRef' field is provided. + // If the MariaDB servers are manually provided by the user via the 'servers' field, this must be set by the user as well. + // +optional + // +operator-sdk:csv:customresourcedefinitions:type=spec,xDescriptors={"urn:alm:descriptor:com.tectonic.ui:booleanSwitch"} + ReplicationSSLEnabled *bool `json:"replicationSSLEnabled,omitempty"` +} + +// SetDefaults sets reasonable defaults. +func (m *MaxScaleTLS) SetDefaults(mdb *MariaDB) { + if !m.Enabled || mdb == nil || !mdb.IsTLSEnabled() { + return + } + + if mdb.Replication().Enabled && m.ReplicationSSLEnabled == nil { + m.ReplicationSSLEnabled = ptr.To(true) + } + if m.ServerCASecretRef == nil { + m.ServerCASecretRef = ptr.To(mdb.TLSCABundleSecretKeyRef().LocalObjectReference) + } + if m.ServerCertSecretRef == nil { + m.ServerCertSecretRef = &LocalObjectReference{ + Name: mdb.TLSClientCertSecretKey().Name, + } + } +} + // MaxScaleMetrics defines the metrics for a Maxscale. type MaxScaleMetrics struct { // Enabled is a flag to enable Metrics @@ -588,6 +677,10 @@ type MaxScaleSpec struct { // +optional // +operator-sdk:csv:customresourcedefinitions:type=spec Metrics *MaxScaleMetrics `json:"metrics,omitempty"` + // TLS defines the PKI to be used with MaxScale. + // +optional + // +operator-sdk:csv:customresourcedefinitions:type=spec + TLS *MaxScaleTLS `json:"tls,omitempty"` // Connection provides a template to define the Connection for MaxScale. // +optional // +operator-sdk:csv:customresourcedefinitions:type=spec @@ -650,6 +743,26 @@ type MaxScaleConfigSyncStatus struct { DatabaseVersion int `json:"databaseVersion"` } +// MaxScaleTLSStatus aggregates the status of the certificates used by the MaxScale instance. +type MaxScaleTLSStatus struct { + // CABundle is the status of the Certificate Authority bundle. + // +optional + // +operator-sdk:csv:customresourcedefinitions:type=status + CABundle []CertificateStatus `json:"caBundle,omitempty"` + // AdminCert is the status of the admin certificate. + // +optional + // +operator-sdk:csv:customresourcedefinitions:type=status + AdminCert *CertificateStatus `json:"adminCert,omitempty"` + // ListenerCert is the status of the listener certificate. + // +optional + // +operator-sdk:csv:customresourcedefinitions:type=status + ListenerCert *CertificateStatus `json:"listenerCert,omitempty"` + // ServerCert is the status of the MariaDB server certificate. + // +optional + // +operator-sdk:csv:customresourcedefinitions:type=status + ServerCert *CertificateStatus `json:"serverCert,omitempty"` +} + // MaxScaleStatus defines the observed state of MaxScale type MaxScaleStatus struct { // Conditions for the MaxScale object. @@ -684,6 +797,10 @@ type MaxScaleStatus struct { // +optional // +operator-sdk:csv:customresourcedefinitions:type=status ConfigSync *MaxScaleConfigSyncStatus `json:"configSync,omitempty"` + // TLS aggregates the status of the certificates used by the MaxScale instance. + // +optional + // +operator-sdk:csv:customresourcedefinitions:type=status + TLS *MaxScaleTLSStatus `json:"tls,omitempty"` } // SetCondition sets a status condition to MaxScale @@ -792,6 +909,10 @@ func (m *MaxScale) SetDefaults(env *environment.OperatorEnv, mariadb *MariaDB) { } } + if m.Spec.TLS != nil && m.IsTLSEnabled() { + m.Spec.TLS.SetDefaults(mariadb) + } + if m.Spec.Affinity != nil { m.Spec.Affinity.SetDefaults(antiAffinityInstances...) } @@ -832,6 +953,11 @@ func (m *MaxScale) AreMetricsEnabled() bool { return ptr.Deref(m.Spec.Metrics, MaxScaleMetrics{}).Enabled } +// IsTLSEnabled indicates whether the MaxScale instance has TLS enabled +func (m *MaxScale) IsTLSEnabled() bool { + return ptr.Deref(m.Spec.TLS, MaxScaleTLS{}).Enabled +} + // APIUrl returns the URL of the admin API pointing to the Kubernetes Service. func (m *MaxScale) APIUrl() string { fqdn := statefulset.ServiceFQDNWithService(m.ObjectMeta, m.Name) @@ -907,8 +1033,29 @@ func (m *MaxScale) DefaultPort() (*int32, error) { return &m.Spec.Services[0].Listener.Port, nil } +// TLSAdminDNSNames are the Service DNS names used by admin TLS certificates. +func (m *MaxScale) TLSAdminDNSNames() []string { + var names []string + names = append(names, statefulset.ServiceNameVariants(m.ObjectMeta, m.Name)...) + names = append(names, statefulset.ServiceNameVariants(m.ObjectMeta, m.GuiServiceKey().Name)...) + names = append(names, statefulset.HeadlessServiceNameVariants(m.ObjectMeta, "*", m.InternalServiceKey().Name)...) + return names +} + +// TLSListenerDNSNames are the Service DNS names used by listener TLS certificates. +func (m *MaxScale) TLSListenerDNSNames() []string { + var names []string + names = append(names, statefulset.ServiceNameVariants(m.ObjectMeta, m.Name)...) + names = append(names, statefulset.HeadlessServiceNameVariants(m.ObjectMeta, "*", m.InternalServiceKey().Name)...) + return names +} + func (m *MaxScale) apiUrlWithAddress(addr string) string { - return fmt.Sprintf("http://%s:%d", addr, m.Spec.Admin.Port) + scheme := "http" + if m.IsTLSEnabled() { + scheme = "https" + } + return fmt.Sprintf("%s://%s:%d", scheme, addr, m.Spec.Admin.Port) } func (m *MaxScale) defaultConnections() int32 { @@ -918,28 +1065,6 @@ func (m *MaxScale) defaultConnections() int32 { return 30 } -// MaxScaleMetricsPasswordSecretFieldPath is the path related to the metrics password Secret field. -const MaxScaleMetricsPasswordSecretFieldPath = ".spec.auth.metricsPasswordSecretKeyRef.name" - -// IndexerFuncForFieldPath returns an indexer function for a given field path. -func (m *MaxScale) IndexerFuncForFieldPath(fieldPath string) (client.IndexerFunc, error) { - switch fieldPath { - case MaxScaleMetricsPasswordSecretFieldPath: - return func(obj client.Object) []string { - maxscale, ok := obj.(*MaxScale) - if !ok { - return nil - } - if maxscale.AreMetricsEnabled() && maxscale.Spec.Auth.MetricsPasswordSecretKeyRef.Name != "" { - return []string{maxscale.Spec.Auth.MetricsPasswordSecretKeyRef.Name} - } - return nil - }, nil - default: - return nil, fmt.Errorf("unsupported field path: %s", fieldPath) - } -} - //+kubebuilder:object:root=true // MaxScaleList contains a list of MaxScale diff --git a/api/v1alpha1/maxscale_types_test.go b/api/v1alpha1/maxscale_types_test.go index a80645b64f..5d10a43da4 100644 --- a/api/v1alpha1/maxscale_types_test.go +++ b/api/v1alpha1/maxscale_types_test.go @@ -584,4 +584,62 @@ var _ = Describe("MaxScale types", func() { ), ) }) + + Context("When setting defaults for MaxScaleTLS", func() { + It("should set defaults when TLS is enabled and MariaDB is provided", func() { + tls := &MaxScaleTLS{ + Enabled: true, + } + mariadb := &MariaDB{ + ObjectMeta: metav1.ObjectMeta{ + Name: "mdb", + }, + Spec: MariaDBSpec{ + TLS: &TLS{ + Enabled: true, + ClientCertSecretRef: &LocalObjectReference{ + Name: "client-cert", + }, + }, + Replication: &Replication{ + Enabled: true, + }, + }, + } + tls.SetDefaults(mariadb) + + Expect(tls.ReplicationSSLEnabled).To(Equal(ptr.To(true))) + Expect(tls.ServerCASecretRef.Name).To(Equal("mdb-ca-bundle")) + Expect(tls.ServerCertSecretRef.Name).To(Equal("client-cert")) + }) + + It("should not set defaults when TLS is disabled", func() { + mariadb := &MariaDB{ + ObjectMeta: metav1.ObjectMeta{ + Name: "mdb", + Namespace: testNamespace, + }, + } + + tls := &MaxScaleTLS{ + Enabled: false, + } + tls.SetDefaults(mariadb) + + Expect(tls.ReplicationSSLEnabled).To(BeNil()) + Expect(tls.ServerCASecretRef).To(BeNil()) + Expect(tls.ServerCertSecretRef).To(BeNil()) + }) + + It("should not set defaults when MariaDB is not provided", func() { + tls := &MaxScaleTLS{ + Enabled: true, + } + tls.SetDefaults(nil) + + Expect(tls.ReplicationSSLEnabled).To(BeNil()) + Expect(tls.ServerCASecretRef).To(BeNil()) + Expect(tls.ServerCertSecretRef).To(BeNil()) + }) + }) }) diff --git a/api/v1alpha1/maxscale_webhook.go b/api/v1alpha1/maxscale_webhook.go index 194f131418..6f3fb439aa 100644 --- a/api/v1alpha1/maxscale_webhook.go +++ b/api/v1alpha1/maxscale_webhook.go @@ -32,6 +32,7 @@ func (r *MaxScale) ValidateCreate() (admission.Warnings, error) { r.validateMonitor, r.validateServices, r.validatePodDisruptionBudget, + r.validateTLS, } for _, fn := range validateFns { if err := fn(); err != nil { @@ -55,6 +56,7 @@ func (r *MaxScale) ValidateUpdate(old runtime.Object) (admission.Warnings, error r.validateMonitor, r.validateServices, r.validatePodDisruptionBudget, + r.validateTLS, } for _, fn := range validateFns { if err := fn(); err != nil { @@ -184,3 +186,39 @@ func (r *MaxScale) validatePodDisruptionBudget() error { } return nil } + +func (r *MaxScale) validateTLS() error { + tls := ptr.Deref(r.Spec.TLS, MaxScaleTLS{}) + if !tls.Enabled { + return nil + } + validationItems := []tlsValidationItem{ + { + tlsValue: r.Spec.TLS, + caSecretRef: tls.AdminCASecretRef, + caFieldPath: "spec.tls.adminCASecretRef", + certSecretRef: tls.AdminCertSecretRef, + certFieldPath: "spec.tls.adminCertSecretRef", + }, + { + tlsValue: r.Spec.TLS, + caSecretRef: tls.ListenerCASecretRef, + caFieldPath: "spec.tls.listenerCASecretRef", + certSecretRef: tls.ListenerCertSecretRef, + certFieldPath: "spec.tls.listenerCertSecretRef", + }, + { + tlsValue: r.Spec.TLS, + caSecretRef: tls.ServerCASecretRef, + caFieldPath: "spec.tls.serverCASecretRef", + certSecretRef: tls.ServerCertSecretRef, + certFieldPath: "spec.tls.serverCertSecretRef", + }, + } + for _, item := range validationItems { + if err := validateTLSCert(&item); err != nil { + return err + } + } + return nil +} diff --git a/api/v1alpha1/maxscale_webhook_test.go b/api/v1alpha1/maxscale_webhook_test.go index 064d6c6329..5061d447ea 100644 --- a/api/v1alpha1/maxscale_webhook_test.go +++ b/api/v1alpha1/maxscale_webhook_test.go @@ -372,6 +372,49 @@ var _ = Describe("MaxScale webhook", func() { }, false, ), + Entry( + "Invalid TLS", + &MaxScale{ + ObjectMeta: meta, + Spec: MaxScaleSpec{ + MariaDBRef: &MariaDBRef{ + ObjectReference: ObjectReference{ + Name: "mariadb", + }, + }, + TLS: &MaxScaleTLS{ + Enabled: true, + ListenerCertSecretRef: &LocalObjectReference{ + Name: "listener-cert", + }, + }, + }, + }, + true, + ), + Entry( + "Valid TLS", + &MaxScale{ + ObjectMeta: meta, + Spec: MaxScaleSpec{ + MariaDBRef: &MariaDBRef{ + ObjectReference: ObjectReference{ + Name: "mariadb", + }, + }, + TLS: &MaxScaleTLS{ + Enabled: true, + ListenerCASecretRef: &LocalObjectReference{ + Name: "listener-ca", + }, + ListenerCertSecretRef: &LocalObjectReference{ + Name: "listener-cert", + }, + }, + }, + }, + false, + ), ) }) @@ -566,6 +609,33 @@ var _ = Describe("MaxScale webhook", func() { }, false, ), + Entry( + "Updating to invalid TLS", + func(mxs *MaxScale) { + mxs.Spec.TLS = &MaxScaleTLS{ + Enabled: true, + ListenerCertSecretRef: &LocalObjectReference{ + Name: "server-cert", + }, + } + }, + true, + ), + Entry( + "Updating to valid TLS", + func(mxs *MaxScale) { + mxs.Spec.TLS = &MaxScaleTLS{ + Enabled: true, + ListenerCASecretRef: &LocalObjectReference{ + Name: "server-ca", + }, + ListenerCertSecretRef: &LocalObjectReference{ + Name: "server-cert", + }, + } + }, + false, + ), ) }) }) diff --git a/api/v1alpha1/suite_test.go b/api/v1alpha1/suite_test.go index 35fd2eaa98..4a831f513b 100644 --- a/api/v1alpha1/suite_test.go +++ b/api/v1alpha1/suite_test.go @@ -9,6 +9,7 @@ import ( "testing" "time" + certmanagerv1 "github.com/cert-manager/cert-manager/pkg/apis/certmanager/v1" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" monitoringv1 "github.com/prometheus-operator/prometheus-operator/pkg/apis/monitoring/v1" @@ -50,11 +51,9 @@ var _ = BeforeSuite(func() { err = AddToScheme(scheme) Expect(err).NotTo(HaveOccurred()) - err = admissionv1.AddToScheme(scheme) - Expect(err).NotTo(HaveOccurred()) - - err = monitoringv1.AddToScheme(scheme) - Expect(err).NotTo(HaveOccurred()) + Expect(admissionv1.AddToScheme(scheme)).NotTo(HaveOccurred()) + Expect(monitoringv1.AddToScheme(scheme)).NotTo(HaveOccurred()) + Expect(certmanagerv1.AddToScheme(scheme)).NotTo(HaveOccurred()) //+kubebuilder:scaffold:scheme diff --git a/api/v1alpha1/user_indexes.go b/api/v1alpha1/user_indexes.go new file mode 100644 index 0000000000..faefc9db8f --- /dev/null +++ b/api/v1alpha1/user_indexes.go @@ -0,0 +1,103 @@ +package v1alpha1 + +import ( + "context" + "fmt" + + "github.com/mariadb-operator/mariadb-operator/pkg/metadata" + "github.com/mariadb-operator/mariadb-operator/pkg/predicate" + "github.com/mariadb-operator/mariadb-operator/pkg/watch" + corev1 "k8s.io/api/core/v1" + ctrlbuilder "sigs.k8s.io/controller-runtime/pkg/builder" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/manager" +) + +const ( + userPasswordSecretFieldPath = ".spec.passwordSecretKeyRef.name" + userPasswordHashSecretFieldPath = ".spec.passwordHashSecretKeyRef.name" + userPasswordPluginNameSecretFieldPath = ".spec.passwordPlugin.pluginNameSecretKeyRef.name" + userPasswordPluginArgSecretFieldPath = ".spec.passwordPlugin.pluginArgSecretKeyRef.name" +) + +// IndexerFuncForFieldPath returns an indexer function for a given field path. +func (m *User) IndexerFuncForFieldPath(fieldPath string) (client.IndexerFunc, error) { + switch fieldPath { + case userPasswordSecretFieldPath: + return func(obj client.Object) []string { + user, ok := obj.(*User) + if !ok { + return nil + } + if user.Spec.PasswordSecretKeyRef != nil && user.Spec.PasswordSecretKeyRef.LocalObjectReference.Name != "" { + return []string{user.Spec.PasswordSecretKeyRef.LocalObjectReference.Name} + } + return nil + }, nil + case userPasswordHashSecretFieldPath: + return func(obj client.Object) []string { + user, ok := obj.(*User) + if !ok { + return nil + } + if user.Spec.PasswordHashSecretKeyRef != nil && user.Spec.PasswordHashSecretKeyRef.LocalObjectReference.Name != "" { + return []string{user.Spec.PasswordHashSecretKeyRef.LocalObjectReference.Name} + } + return nil + }, nil + case userPasswordPluginNameSecretFieldPath: + return func(obj client.Object) []string { + user, ok := obj.(*User) + if !ok { + return nil + } + if user.Spec.PasswordPlugin.PluginNameSecretKeyRef != nil && + user.Spec.PasswordPlugin.PluginNameSecretKeyRef.LocalObjectReference.Name != "" { + return []string{user.Spec.PasswordPlugin.PluginNameSecretKeyRef.LocalObjectReference.Name} + } + return nil + }, nil + case userPasswordPluginArgSecretFieldPath: + return func(obj client.Object) []string { + user, ok := obj.(*User) + if !ok { + return nil + } + if user.Spec.PasswordPlugin.PluginArgSecretKeyRef != nil && + user.Spec.PasswordPlugin.PluginArgSecretKeyRef.LocalObjectReference.Name != "" { + return []string{user.Spec.PasswordPlugin.PluginArgSecretKeyRef.LocalObjectReference.Name} + } + return nil + }, nil + default: + return nil, fmt.Errorf("unsupported field path: %s", fieldPath) + } +} + +// IndexUser watches and indexes external resources referred by User resources. +func IndexUser(ctx context.Context, mgr manager.Manager, builder *ctrlbuilder.Builder, client client.Client) error { + watcherIndexer := watch.NewWatcherIndexer(mgr, builder, client) + + secretFieldPaths := []string{ + userPasswordSecretFieldPath, + userPasswordHashSecretFieldPath, + userPasswordPluginNameSecretFieldPath, + userPasswordPluginArgSecretFieldPath, + } + for _, fieldPath := range secretFieldPaths { + if err := watcherIndexer.Watch( + ctx, + &corev1.Secret{}, + &User{}, + &UserList{}, + fieldPath, + ctrlbuilder.WithPredicates( + predicate.PredicateWithLabel(metadata.WatchLabel), + ), + ); err != nil { + return fmt.Errorf("error watching: %v", err) + } + } + + return nil +} diff --git a/api/v1alpha1/user_types.go b/api/v1alpha1/user_types.go index 0e245d6873..5a0f4368e7 100644 --- a/api/v1alpha1/user_types.go +++ b/api/v1alpha1/user_types.go @@ -1,6 +1,7 @@ package v1alpha1 import ( + "errors" "fmt" "k8s.io/apimachinery/pkg/api/meta" @@ -8,6 +9,48 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client" ) +// TLSRequirements specifies TLS requirements for the user to connect. See: https://mariadb.com/kb/en/securing-connections-for-client-and-server/#requiring-tls. +type TLSRequirements struct { + // SSL indicates that the user must connect via TLS. + // +optional + // +operator-sdk:csv:customresourcedefinitions:type=spec + SSL *bool `json:"ssl,omitempty"` + // X509 indicates that the user must provide a valid x509 certificate to connect. + // +optional + // +operator-sdk:csv:customresourcedefinitions:type=spec + X509 *bool `json:"x509,omitempty"` + // Issuer indicates that the TLS certificate provided by the user must be issued by a specific issuer. + // +optional + // +operator-sdk:csv:customresourcedefinitions:type=spec + Issuer *string `json:"issuer,omitempty"` + // Subject indicates that the TLS certificate provided by the user must have a specific subject. + // +optional + // +operator-sdk:csv:customresourcedefinitions:type=spec + Subject *string `json:"subject,omitempty"` +} + +// Validate ensures that TLSRequirements provides legit options. +func (u *TLSRequirements) Validate() error { + // see: https://mariadb.com/kb/en/securing-connections-for-client-and-server/#requiring-tls + count := 0 + if u.SSL != nil && *u.SSL { + count++ + } + if u.X509 != nil && *u.X509 { + count++ + } + if (u.Issuer != nil && *u.Issuer != "") || (u.Subject != nil && *u.Subject != "") { + count++ + } + if count > 1 { + return errors.New("only one of [SSL, X509, (Issuer, Subject)] can be set at a time") + } + if count == 0 { + return errors.New("at least one field [SSL, X509, (Issuer, Subject)] must be set") + } + return nil +} + // UserSpec defines the desired state of User type UserSpec struct { // SQLTemplate defines templates to configure SQL objects. @@ -32,6 +75,10 @@ type UserSpec struct { // +optional // +operator-sdk:csv:customresourcedefinitions:type=spec,xDescriptors={"urn:alm:descriptor:com.tectonic.ui:advanced"} PasswordPlugin PasswordPlugin `json:"passwordPlugin,omitempty"` + // Require specifies TLS requirements for the user to connect. See: https://mariadb.com/kb/en/securing-connections-for-client-and-server/#requiring-tls. + // +optional + // +operator-sdk:csv:customresourcedefinitions:type=spec,xDescriptors={"urn:alm:descriptor:com.tectonic.ui:advanced"} + Require *TLSRequirements `json:"require,omitempty"` // MaxUserConnections defines the maximum number of simultaneous connections that the User can establish. // +optional // +kubebuilder:default=10 @@ -125,66 +172,6 @@ func (u *User) CleanupPolicy() *CleanupPolicy { return u.Spec.CleanupPolicy } -// UserPasswordSecretFieldPath is the path related to the password Secret field. -const UserPasswordSecretFieldPath = ".spec.passwordSecretKeyRef.name" -const UserPasswordHashSecretFieldPath = ".spec.passwordHashSecretKeyRef.name" -const UserPasswordPluginNameSecretFieldPath = ".spec.passwordPlugin.pluginNameSecretKeyRef.name" -const UserPasswordPluginArgSecretFieldPath = ".spec.passwordPlugin.pluginArgSecretKeyRef.name" - -// IndexerFuncForFieldPath returns an indexer function for a given field path. -func (m *User) IndexerFuncForFieldPath(fieldPath string) (client.IndexerFunc, error) { - switch fieldPath { - case UserPasswordSecretFieldPath: - return func(obj client.Object) []string { - user, ok := obj.(*User) - if !ok { - return nil - } - if user.Spec.PasswordSecretKeyRef != nil && user.Spec.PasswordSecretKeyRef.LocalObjectReference.Name != "" { - return []string{user.Spec.PasswordSecretKeyRef.LocalObjectReference.Name} - } - return nil - }, nil - case UserPasswordHashSecretFieldPath: - return func(obj client.Object) []string { - user, ok := obj.(*User) - if !ok { - return nil - } - if user.Spec.PasswordHashSecretKeyRef != nil && user.Spec.PasswordHashSecretKeyRef.LocalObjectReference.Name != "" { - return []string{user.Spec.PasswordHashSecretKeyRef.LocalObjectReference.Name} - } - return nil - }, nil - case UserPasswordPluginNameSecretFieldPath: - return func(obj client.Object) []string { - user, ok := obj.(*User) - if !ok { - return nil - } - if user.Spec.PasswordPlugin.PluginNameSecretKeyRef != nil && - user.Spec.PasswordPlugin.PluginNameSecretKeyRef.LocalObjectReference.Name != "" { - return []string{user.Spec.PasswordPlugin.PluginNameSecretKeyRef.LocalObjectReference.Name} - } - return nil - }, nil - case UserPasswordPluginArgSecretFieldPath: - return func(obj client.Object) []string { - user, ok := obj.(*User) - if !ok { - return nil - } - if user.Spec.PasswordPlugin.PluginArgSecretKeyRef != nil && - user.Spec.PasswordPlugin.PluginArgSecretKeyRef.LocalObjectReference.Name != "" { - return []string{user.Spec.PasswordPlugin.PluginArgSecretKeyRef.LocalObjectReference.Name} - } - return nil - }, nil - default: - return nil, fmt.Errorf("unsupported field path: %s", fieldPath) - } -} - // +kubebuilder:object:root=true // UserList contains a list of User diff --git a/api/v1alpha1/user_webhook.go b/api/v1alpha1/user_webhook.go index 08dc867c74..e11d52fb8e 100644 --- a/api/v1alpha1/user_webhook.go +++ b/api/v1alpha1/user_webhook.go @@ -26,6 +26,7 @@ func (r *User) ValidateCreate() (admission.Warnings, error) { validateFns := []func() error{ r.validatePassword, r.validateCleanupPolicy, + r.validateRequire, } for _, fn := range validateFns { if err := fn(); err != nil { @@ -46,6 +47,7 @@ func (r *User) ValidateUpdate(old runtime.Object) (admission.Warnings, error) { validateFns := []func() error{ r.validatePassword, r.validateCleanupPolicy, + r.validateRequire, } for _, fn := range validateFns { if err := fn(); err != nil { @@ -108,3 +110,16 @@ func (r *User) validateCleanupPolicy() error { } return nil } + +func (u *User) validateRequire() error { + if require := u.Spec.Require; require != nil { + if err := require.Validate(); err != nil { + return field.Invalid( + field.NewPath("spec").Child("require"), + u.Spec.Require, + err.Error(), + ) + } + } + return nil +} diff --git a/api/v1alpha1/user_webhook_test.go b/api/v1alpha1/user_webhook_test.go index a784b31eda..20256eda6c 100644 --- a/api/v1alpha1/user_webhook_test.go +++ b/api/v1alpha1/user_webhook_test.go @@ -11,10 +11,6 @@ import ( var _ = Describe("User webhook", func() { Context("When creating a User", func() { - key := types.NamespacedName{ - Name: "user-create-webhook", - Namespace: testNamespace, - } DescribeTable( "Should validate", func(user *User, wantErr bool) { @@ -29,8 +25,8 @@ var _ = Describe("User webhook", func() { "Valid cleanupPolicy", &User{ ObjectMeta: metav1.ObjectMeta{ - Name: key.Name, - Namespace: key.Namespace, + Name: "test-user-valid-cleanuppolicy", + Namespace: testNamespace, }, Spec: UserSpec{ SQLTemplate: SQLTemplate{ @@ -57,8 +53,8 @@ var _ = Describe("User webhook", func() { "Invalid cleanupPolicy", &User{ ObjectMeta: metav1.ObjectMeta{ - Name: key.Name, - Namespace: key.Namespace, + Name: "test-user-invalid-cleanuppolicy", + Namespace: testNamespace, }, Spec: UserSpec{ SQLTemplate: SQLTemplate{ @@ -81,6 +77,65 @@ var _ = Describe("User webhook", func() { }, true, ), + Entry( + "Valid require", + &User{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-user-valid-require", + Namespace: testNamespace, + }, + Spec: UserSpec{ + MariaDBRef: MariaDBRef{ + ObjectReference: ObjectReference{ + Name: "mariadb-webhook", + }, + WaitForIt: true, + }, + PasswordSecretKeyRef: &SecretKeySelector{ + LocalObjectReference: LocalObjectReference{ + Name: "user-mariadb-webhook-root", + }, + Key: "password", + }, + Require: &TLSRequirements{ + Issuer: ptr.To("/CN=mariadb-galera-ca"), + Subject: ptr.To("/CN=mariadb-galera-ca"), + }, + MaxUserConnections: 10, + }, + }, + false, + ), + Entry( + "Invalid require", + &User{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-user-invalid-require", + Namespace: testNamespace, + }, + Spec: UserSpec{ + MariaDBRef: MariaDBRef{ + ObjectReference: ObjectReference{ + Name: "mariadb-webhook", + }, + WaitForIt: true, + }, + PasswordSecretKeyRef: &SecretKeySelector{ + LocalObjectReference: LocalObjectReference{ + Name: "user-mariadb-webhook-root", + }, + Key: "password", + }, + Require: &TLSRequirements{ + X509: ptr.To(true), + Issuer: ptr.To("/CN=mariadb-galera-ca"), + Subject: ptr.To("/CN=mariadb-galera-ca"), + }, + MaxUserConnections: 10, + }, + }, + true, + ), ) }) @@ -196,18 +251,20 @@ var _ = Describe("User webhook", func() { false, ), Entry( - "Updating to valid CleanupPolicy", + "Updating CleanupPolicy", func(umdb *User) { umdb.Spec.CleanupPolicy = ptr.To(CleanupPolicySkip) }, false, ), Entry( - "Updating to invalid CleanupPolicy", + "Updating TLSRequirements", func(umdb *User) { - umdb.Spec.CleanupPolicy = ptr.To(CleanupPolicy("")) + umdb.Spec.Require = &TLSRequirements{ + X509: ptr.To(true), + } }, - true, + false, ), ) }) diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index 0ebce568a8..37665e2a26 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -29,6 +29,7 @@ THE SOFTWARE. package v1alpha1 import ( + metav1 "github.com/cert-manager/cert-manager/pkg/apis/meta/v1" "github.com/mariadb-operator/mariadb-operator/pkg/galera/recovery" appsv1 "k8s.io/api/apps/v1" corev1 "k8s.io/api/core/v1" @@ -339,6 +340,23 @@ func (in *CSIVolumeSource) DeepCopy() *CSIVolumeSource { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *CertificateStatus) DeepCopyInto(out *CertificateStatus) { + *out = *in + in.NotAfter.DeepCopyInto(&out.NotAfter) + in.NotBefore.DeepCopyInto(&out.NotBefore) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new CertificateStatus. +func (in *CertificateStatus) DeepCopy() *CertificateStatus { + if in == nil { + return nil + } + out := new(CertificateStatus) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *ConfigMapKeySelector) DeepCopyInto(out *ConfigMapKeySelector) { *out = *in @@ -475,6 +493,11 @@ func (in *ConnectionSpec) DeepCopyInto(out *ConnectionSpec) { **out = **in } out.PasswordSecretKeyRef = in.PasswordSecretKeyRef + if in.TLSClientCertSecretRef != nil { + in, out := &in.TLSClientCertSecretRef, &out.TLSClientCertSecretRef + *out = new(LocalObjectReference) + **out = **in + } if in.Database != nil { in, out := &in.Database, &out.Database *out = new(string) @@ -1761,6 +1784,11 @@ func (in *MariaDBMaxScaleSpec) DeepCopyInto(out *MariaDBMaxScaleSpec) { *out = new(MaxScaleMetrics) (*in).DeepCopyInto(*out) } + if in.TLS != nil { + in, out := &in.TLS, &out.TLS + *out = new(MaxScaleTLS) + (*in).DeepCopyInto(*out) + } if in.Connection != nil { in, out := &in.Connection, &out.Connection *out = new(ConnectionTemplate) @@ -1892,6 +1920,11 @@ func (in *MariaDBSpec) DeepCopyInto(out *MariaDBSpec) { *out = new(MariadbMetrics) (*in).DeepCopyInto(*out) } + if in.TLS != nil { + in, out := &in.TLS, &out.TLS + *out = new(TLS) + (*in).DeepCopyInto(*out) + } if in.Replication != nil { in, out := &in.Replication, &out.Replication *out = new(Replication) @@ -1997,6 +2030,11 @@ func (in *MariaDBStatus) DeepCopyInto(out *MariaDBStatus) { (*out)[key] = val } } + if in.TLS != nil { + in, out := &in.TLS, &out.TLS + *out = new(MariaDBTLSStatus) + (*in).DeepCopyInto(*out) + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MariaDBStatus. @@ -2009,6 +2047,38 @@ func (in *MariaDBStatus) DeepCopy() *MariaDBStatus { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *MariaDBTLSStatus) DeepCopyInto(out *MariaDBTLSStatus) { + *out = *in + if in.CABundle != nil { + in, out := &in.CABundle, &out.CABundle + *out = make([]CertificateStatus, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + if in.ServerCert != nil { + in, out := &in.ServerCert, &out.ServerCert + *out = new(CertificateStatus) + (*in).DeepCopyInto(*out) + } + if in.ClientCert != nil { + in, out := &in.ClientCert, &out.ClientCert + *out = new(CertificateStatus) + (*in).DeepCopyInto(*out) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MariaDBTLSStatus. +func (in *MariaDBTLSStatus) DeepCopy() *MariaDBTLSStatus { + if in == nil { + return nil + } + out := new(MariaDBTLSStatus) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *MariadbMetrics) DeepCopyInto(out *MariadbMetrics) { *out = *in @@ -2461,6 +2531,11 @@ func (in *MaxScaleSpec) DeepCopyInto(out *MaxScaleSpec) { *out = new(MaxScaleMetrics) (*in).DeepCopyInto(*out) } + if in.TLS != nil { + in, out := &in.TLS, &out.TLS + *out = new(MaxScaleTLS) + (*in).DeepCopyInto(*out) + } if in.Connection != nil { in, out := &in.Connection, &out.Connection *out = new(ConnectionTemplate) @@ -2543,6 +2618,11 @@ func (in *MaxScaleStatus) DeepCopyInto(out *MaxScaleStatus) { *out = new(MaxScaleConfigSyncStatus) **out = **in } + if in.TLS != nil { + in, out := &in.TLS, &out.TLS + *out = new(MaxScaleTLSStatus) + (*in).DeepCopyInto(*out) + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MaxScaleStatus. @@ -2555,6 +2635,113 @@ func (in *MaxScaleStatus) DeepCopy() *MaxScaleStatus { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *MaxScaleTLS) DeepCopyInto(out *MaxScaleTLS) { + *out = *in + if in.AdminCASecretRef != nil { + in, out := &in.AdminCASecretRef, &out.AdminCASecretRef + *out = new(LocalObjectReference) + **out = **in + } + if in.AdminCertSecretRef != nil { + in, out := &in.AdminCertSecretRef, &out.AdminCertSecretRef + *out = new(LocalObjectReference) + **out = **in + } + if in.AdminCertIssuerRef != nil { + in, out := &in.AdminCertIssuerRef, &out.AdminCertIssuerRef + *out = new(metav1.ObjectReference) + **out = **in + } + if in.ListenerCASecretRef != nil { + in, out := &in.ListenerCASecretRef, &out.ListenerCASecretRef + *out = new(LocalObjectReference) + **out = **in + } + if in.ListenerCertSecretRef != nil { + in, out := &in.ListenerCertSecretRef, &out.ListenerCertSecretRef + *out = new(LocalObjectReference) + **out = **in + } + if in.ListenerCertIssuerRef != nil { + in, out := &in.ListenerCertIssuerRef, &out.ListenerCertIssuerRef + *out = new(metav1.ObjectReference) + **out = **in + } + if in.ServerCASecretRef != nil { + in, out := &in.ServerCASecretRef, &out.ServerCASecretRef + *out = new(LocalObjectReference) + **out = **in + } + if in.ServerCertSecretRef != nil { + in, out := &in.ServerCertSecretRef, &out.ServerCertSecretRef + *out = new(LocalObjectReference) + **out = **in + } + if in.VerifyPeerCertificate != nil { + in, out := &in.VerifyPeerCertificate, &out.VerifyPeerCertificate + *out = new(bool) + **out = **in + } + if in.VerifyPeerHost != nil { + in, out := &in.VerifyPeerHost, &out.VerifyPeerHost + *out = new(bool) + **out = **in + } + if in.ReplicationSSLEnabled != nil { + in, out := &in.ReplicationSSLEnabled, &out.ReplicationSSLEnabled + *out = new(bool) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MaxScaleTLS. +func (in *MaxScaleTLS) DeepCopy() *MaxScaleTLS { + if in == nil { + return nil + } + out := new(MaxScaleTLS) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *MaxScaleTLSStatus) DeepCopyInto(out *MaxScaleTLSStatus) { + *out = *in + if in.CABundle != nil { + in, out := &in.CABundle, &out.CABundle + *out = make([]CertificateStatus, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + if in.AdminCert != nil { + in, out := &in.AdminCert, &out.AdminCert + *out = new(CertificateStatus) + (*in).DeepCopyInto(*out) + } + if in.ListenerCert != nil { + in, out := &in.ListenerCert, &out.ListenerCert + *out = new(CertificateStatus) + (*in).DeepCopyInto(*out) + } + if in.ServerCert != nil { + in, out := &in.ServerCert, &out.ServerCert + *out = new(CertificateStatus) + (*in).DeepCopyInto(*out) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MaxScaleTLSStatus. +func (in *MaxScaleTLSStatus) DeepCopy() *MaxScaleTLSStatus { + if in == nil { + return nil + } + out := new(MaxScaleTLSStatus) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *Metadata) DeepCopyInto(out *Metadata) { *out = *in @@ -3114,6 +3301,11 @@ func (in *ProbeHandler) DeepCopyInto(out *ProbeHandler) { *out = new(HTTPGetAction) **out = **in } + if in.TCPSocket != nil { + in, out := &in.TCPSocket, &out.TCPSocket + *out = new(TCPSocketAction) + **out = **in + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ProbeHandler. @@ -3428,7 +3620,7 @@ func (in *S3) DeepCopyInto(out *S3) { } if in.TLS != nil { in, out := &in.TLS, &out.TLS - *out = new(TLS) + *out = new(TLSS3) (*in).DeepCopyInto(*out) } } @@ -3925,12 +4117,63 @@ func (in *SuspendTemplate) DeepCopy() *SuspendTemplate { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *TCPSocketAction) DeepCopyInto(out *TCPSocketAction) { + *out = *in + out.Port = in.Port +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TCPSocketAction. +func (in *TCPSocketAction) DeepCopy() *TCPSocketAction { + if in == nil { + return nil + } + out := new(TCPSocketAction) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *TLS) DeepCopyInto(out *TLS) { *out = *in - if in.CASecretKeyRef != nil { - in, out := &in.CASecretKeyRef, &out.CASecretKeyRef - *out = new(SecretKeySelector) + if in.ServerCASecretRef != nil { + in, out := &in.ServerCASecretRef, &out.ServerCASecretRef + *out = new(LocalObjectReference) + **out = **in + } + if in.ServerCertSecretRef != nil { + in, out := &in.ServerCertSecretRef, &out.ServerCertSecretRef + *out = new(LocalObjectReference) + **out = **in + } + if in.ServerCertIssuerRef != nil { + in, out := &in.ServerCertIssuerRef, &out.ServerCertIssuerRef + *out = new(metav1.ObjectReference) + **out = **in + } + if in.ClientCASecretRef != nil { + in, out := &in.ClientCASecretRef, &out.ClientCASecretRef + *out = new(LocalObjectReference) + **out = **in + } + if in.ClientCertSecretRef != nil { + in, out := &in.ClientCertSecretRef, &out.ClientCertSecretRef + *out = new(LocalObjectReference) + **out = **in + } + if in.ClientCertIssuerRef != nil { + in, out := &in.ClientCertIssuerRef, &out.ClientCertIssuerRef + *out = new(metav1.ObjectReference) + **out = **in + } + if in.GaleraServerSSLMode != nil { + in, out := &in.GaleraServerSSLMode, &out.GaleraServerSSLMode + *out = new(string) + **out = **in + } + if in.GaleraClientSSLMode != nil { + in, out := &in.GaleraClientSSLMode, &out.GaleraClientSSLMode + *out = new(string) **out = **in } } @@ -3945,6 +4188,61 @@ func (in *TLS) DeepCopy() *TLS { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *TLSRequirements) DeepCopyInto(out *TLSRequirements) { + *out = *in + if in.SSL != nil { + in, out := &in.SSL, &out.SSL + *out = new(bool) + **out = **in + } + if in.X509 != nil { + in, out := &in.X509, &out.X509 + *out = new(bool) + **out = **in + } + if in.Issuer != nil { + in, out := &in.Issuer, &out.Issuer + *out = new(string) + **out = **in + } + if in.Subject != nil { + in, out := &in.Subject, &out.Subject + *out = new(string) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TLSRequirements. +func (in *TLSRequirements) DeepCopy() *TLSRequirements { + if in == nil { + return nil + } + out := new(TLSRequirements) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *TLSS3) DeepCopyInto(out *TLSS3) { + *out = *in + if in.CASecretKeyRef != nil { + in, out := &in.CASecretKeyRef, &out.CASecretKeyRef + *out = new(SecretKeySelector) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TLSS3. +func (in *TLSS3) DeepCopy() *TLSS3 { + if in == nil { + return nil + } + out := new(TLSS3) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *TopologySpreadConstraint) DeepCopyInto(out *TopologySpreadConstraint) { *out = *in @@ -4085,6 +4383,11 @@ func (in *UserSpec) DeepCopyInto(out *UserSpec) { **out = **in } in.PasswordPlugin.DeepCopyInto(&out.PasswordPlugin) + if in.Require != nil { + in, out := &in.Require, &out.Require + *out = new(TLSRequirements) + (*in).DeepCopyInto(*out) + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new UserSpec. diff --git a/cmd/agent/main.go b/cmd/agent/main.go index bdb4b54373..4e0bb939fc 100644 --- a/cmd/agent/main.go +++ b/cmd/agent/main.go @@ -4,8 +4,12 @@ import ( "context" "fmt" "os" + "os/signal" + "sync" + "syscall" "time" + "github.com/go-logr/logr" mariadbv1alpha1 "github.com/mariadb-operator/mariadb-operator/api/v1alpha1" "github.com/mariadb-operator/mariadb-operator/pkg/environment" "github.com/mariadb-operator/mariadb-operator/pkg/galera/agent/handler" @@ -13,6 +17,7 @@ import ( "github.com/mariadb-operator/mariadb-operator/pkg/galera/agent/server" "github.com/mariadb-operator/mariadb-operator/pkg/galera/filemanager" "github.com/mariadb-operator/mariadb-operator/pkg/galera/state" + mdbhttp "github.com/mariadb-operator/mariadb-operator/pkg/http" kubeauth "github.com/mariadb-operator/mariadb-operator/pkg/kubernetes/auth" "github.com/mariadb-operator/mariadb-operator/pkg/log" "github.com/spf13/cobra" @@ -28,6 +33,7 @@ var ( scheme = runtime.NewScheme() logger = ctrl.Log addr string + probeAddr string configDir string stateDir string @@ -49,7 +55,9 @@ func init() { utilruntime.Must(clientgoscheme.AddToScheme(scheme)) utilruntime.Must(mariadbv1alpha1.AddToScheme(scheme)) - RootCmd.Flags().StringVar(&addr, "addr", ":5555", "The address that the HTTP server binds to") + RootCmd.Flags().StringVar(&addr, "addr", ":5555", "The address that the HTTP(s) API server binds to") + RootCmd.Flags().StringVar(&probeAddr, "probe-addr", ":5566", "The address that the HTTP probe server binds to") + RootCmd.Flags().StringVar(&configDir, "config-dir", "/etc/mysql/mariadb.conf.d", "The directory that contains MariaDB configuration files") RootCmd.Flags().StringVar(&stateDir, "state-dir", "/var/lib/mysql", "The directory that contains MariaDB state files") @@ -79,7 +87,7 @@ var RootCmd = &cobra.Command{ fmt.Printf("error setting up logger: %v\n", err) os.Exit(1) } - logger.Info("Starting agent") + logger.Info("Agent starting") env, err := environment.GetPodEnv(context.Background()) if err != nil { @@ -98,65 +106,53 @@ var RootCmd = &cobra.Command{ os.Exit(1) } - mariadbKey := types.NamespacedName{ - Name: env.MariadbName, - Namespace: env.PodNamespace, - } - handlerLogger := logger.WithName("handler") - handler := handler.NewHandler( - mariadbKey, - k8sClient, + apiServer, err := getAPIServer( + env, fileManager, + k8sClient, state, - &handlerLogger, + logger, ) - - routerOpts := []router.Option{ - router.WithCompressLevel(compressLevel), - router.WithRateLimit(rateLimitRequests, rateLimitDuration), + if err != nil { + logger.Error(err, "Error creating API server") + os.Exit(1) } - if kubernetesAuth && kubernetesTrustedName != "" && kubernetesTrustedNamespace != "" { - logger.Info("Configuring Kubernetes authentication") - - routerOpts = append(routerOpts, router.WithKubernetesAuth( - kubernetesAuth, - &kubeauth.Trusted{ - ServiceAccountName: kubernetesTrustedName, - ServiceAccountNamespace: kubernetesTrustedNamespace, - }, - )) - } else if basicAuth && basicAuthUsername != "" && basicAuthPasswordPath != "" { - logger.Info("Configuring basic authentication") - - basicAuthPassword, err := os.ReadFile(basicAuthPasswordPath) - if err != nil { - logger.Error(err, "Error reading basic-auth password") - os.Exit(1) - } - routerOpts = append(routerOpts, router.WithBasicAuth( - basicAuth, - basicAuthUsername, - string(basicAuthPassword), - )) + probeServer, err := getProbeServer(env, k8sClient) + if err != nil { + logger.Error(err, "Error creating probe server") + os.Exit(1) } - router := router.NewRouter( - handler, - k8sClient, - logger, - routerOpts..., - ) - serverLogger := logger.WithName("server") - server := server.NewServer( - addr, - router, - &serverLogger, - server.WithGracefulShutdownTimeout(gracefulShutdownTimeout), - ) - if err := server.Start(context.Background()); err != nil { + ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGHUP, syscall.SIGINT, syscall.SIGTERM, syscall.SIGQUIT) + defer cancel() + errChan := make(chan error, 2) + + var wg sync.WaitGroup + wg.Add(2) + go func() { + defer wg.Done() + + if err := apiServer.Start(ctx); err != nil { + errChan <- fmt.Errorf("error starting API server: %v", err) + } + }() + go func() { + defer wg.Done() + + if err := probeServer.Start(ctx); err != nil { + errChan <- fmt.Errorf("error starting probe server: %v", err) + } + }() + go func() { + wg.Wait() + close(errChan) + }() + + if err, ok := <-errChan; ok { logger.Error(err, "Server error") os.Exit(1) } + logger.Info("Agent stopped") }, } @@ -171,3 +167,107 @@ func getK8sClient() (client.Client, error) { } return k8sClient, nil } + +func getAPIServer(env *environment.PodEnvironment, fileManager *filemanager.FileManager, k8sClient client.Client, state *state.State, + logger logr.Logger) (*server.Server, error) { + apiLogger := logger.WithName("api") + mux := &sync.RWMutex{} + + handler := handler.NewGalera( + fileManager, + state, + mdbhttp.NewResponseWriter(&apiLogger), + mux, + &apiLogger, + ) + + routerOpts := []router.Option{ + router.WithCompressLevel(compressLevel), + router.WithRateLimit(rateLimitRequests, rateLimitDuration), + } + if kubernetesAuth && kubernetesTrustedName != "" && kubernetesTrustedNamespace != "" { + apiLogger.Info("Configuring Kubernetes authentication") + + routerOpts = append(routerOpts, router.WithKubernetesAuth( + kubernetesAuth, + &kubeauth.Trusted{ + ServiceAccountName: kubernetesTrustedName, + ServiceAccountNamespace: kubernetesTrustedNamespace, + }, + )) + } else if basicAuth && basicAuthUsername != "" && basicAuthPasswordPath != "" { + apiLogger.Info("Configuring basic authentication") + + basicAuthPassword, err := os.ReadFile(basicAuthPasswordPath) + if err != nil { + return nil, err + } + routerOpts = append(routerOpts, router.WithBasicAuth( + basicAuth, + basicAuthUsername, + string(basicAuthPassword), + )) + } + router := router.NewGaleraRouter( + handler, + k8sClient, + apiLogger, + routerOpts..., + ) + + serverOpts := []server.Option{ + server.WithGracefulShutdownTimeout(gracefulShutdownTimeout), + } + isTLSEnabled, err := env.IsTLSEnabled() + if err != nil { + return nil, err + } + if isTLSEnabled { + serverOpts = append(serverOpts, []server.Option{ + server.WithTLSEnabled(isTLSEnabled), + server.WithTLSCAPath(env.TLSCACertPath), + server.WithTLSCertPath(env.TLSServerCertPath), + server.WithTLSKeyPath(env.TLSServerKeyPath), + }...) + } + + server, err := server.NewServer( + addr, + router, + &apiLogger, + serverOpts..., + ) + if err != nil { + return nil, err + } + return server, nil +} + +func getProbeServer(env *environment.PodEnvironment, k8sClient client.Client) (*server.Server, error) { + probeLogger := logger.WithName("probe") + mariadbKey := types.NamespacedName{ + Name: env.MariadbName, + Namespace: env.PodNamespace, + } + + handler := handler.NewProbe( + mariadbKey, + k8sClient, + mdbhttp.NewResponseWriter(&probeLogger), + &probeLogger, + ) + router := router.NewProbeRouter( + handler, + probeLogger, + ) + + server, err := server.NewServer( + probeAddr, + router, + &probeLogger, + ) + if err != nil { + return nil, err + } + return server, nil +} diff --git a/cmd/controller/cert_controller.go b/cmd/controller/cert_controller.go index c5b3d3e57a..351154c316 100644 --- a/cmd/controller/cert_controller.go +++ b/cmd/controller/cert_controller.go @@ -1,11 +1,13 @@ package main import ( + "errors" "os" "time" "github.com/mariadb-operator/mariadb-operator/internal/controller" "github.com/mariadb-operator/mariadb-operator/pkg/log" + "github.com/mariadb-operator/mariadb-operator/pkg/pki" "github.com/spf13/cobra" "k8s.io/apimachinery/pkg/types" ctrl "sigs.k8s.io/controller-runtime" @@ -14,10 +16,10 @@ import ( var ( caSecretName, caSecretNamespace, caCommonName string - caValidity time.Duration + caLifetime time.Duration certSecretName, certSecretNamespace string - certValidity time.Duration - lookaheadValidity time.Duration + certLifetime time.Duration + renewBeforePercentage int32 serviceName, serviceNamespace string requeueDuration time.Duration ) @@ -28,17 +30,20 @@ func init() { certControllerCmd.Flags().StringVar(&caSecretNamespace, "ca-secret-namespace", "default", "Namespace of the Secret to store the CA certificate for webhook") certControllerCmd.Flags().StringVar(&caCommonName, "ca-common-name", "mariadb-operator", "CA certificate common name") - certControllerCmd.Flags().DurationVar(&caValidity, "ca-validity", 4*365*24*time.Hour, "CA certificate validity") + certControllerCmd.Flags().DurationVar(&caLifetime, "ca-lifetime", pki.DefaultCALifetime, "CA certificate lifetime") certControllerCmd.Flags().StringVar(&certSecretName, "cert-secret-name", "mariadb-operator-webhook-cert", "Secret to store the certificate for webhook") certControllerCmd.Flags().StringVar(&certSecretNamespace, "cert-secret-namespace", "default", "Namespace of the Secret to store the certificate for webhook") - certControllerCmd.Flags().DurationVar(&certValidity, "cert-validity", 365*24*time.Hour, "Certificate validity") - certControllerCmd.Flags().DurationVar(&lookaheadValidity, "lookahead-validity", 90*24*time.Hour, - "Lookahead validity used to determine whether a certificate is valid or not") + certControllerCmd.Flags().DurationVar(&certLifetime, "cert-lifetime", pki.DefaultCertLifetime, "Certificate lifetime") + certControllerCmd.Flags().Int32Var(&renewBeforePercentage, "renew-before-percentage", pki.DefaultRenewBeforePercentage, + "How long before the certificate expiration should the renewal process be triggered."+ + "For example, if a certificate is valid for 60 minutes, and renew-before-percentage=25, "+ + "cert-controller will begin to attempt to renew the certificate 45 minutes after it was issued"+ + "(i.e. when there are 15 minutes (25%) remaining until the certificate is no longer valid).") certControllerCmd.Flags().StringVar(&serviceName, "service-name", "mariadb-operator-webhook", "Webhook service name") certControllerCmd.Flags().StringVar(&serviceNamespace, "service-namespace", "default", "Webhook service namespace") - certControllerCmd.Flags().DurationVar(&requeueDuration, "requeue-duration", time.Minute*5, + certControllerCmd.Flags().DurationVar(&requeueDuration, "requeue-duration", 5*time.Minute, "Time duration between reconciling webhook config for new certs") } @@ -49,6 +54,15 @@ var certControllerCmd = &cobra.Command{ Run: func(cmd *cobra.Command, args []string) { log.SetupLogger(logLevel, logTimeEncoder, logDev) + if !(renewBeforePercentage >= 10 && renewBeforePercentage <= 90) { + setupLog.Error(errors.New( + "renew-before-percentage must be between [10, 90]"), + "invalid renew-before-percentage", + "value", renewBeforePercentage, + ) + os.Exit(1) + } + mgr, err := ctrl.NewManager(ctrl.GetConfigOrDie(), ctrl.Options{ Scheme: scheme, Metrics: metricsserver.Options{ @@ -73,13 +87,13 @@ var certControllerCmd = &cobra.Command{ Namespace: caSecretNamespace, }, caCommonName, - caValidity, + caLifetime, types.NamespacedName{ Name: certSecretName, Namespace: certSecretNamespace, }, - certValidity, - lookaheadValidity, + certLifetime, + renewBeforePercentage, types.NamespacedName{ Name: serviceName, Namespace: serviceNamespace, diff --git a/cmd/controller/main.go b/cmd/controller/main.go index b303fd16a3..30ba119e69 100644 --- a/cmd/controller/main.go +++ b/cmd/controller/main.go @@ -7,6 +7,7 @@ import ( "syscall" "time" + certmanagerv1 "github.com/cert-manager/cert-manager/pkg/apis/certmanager/v1" mariadbv1alpha1 "github.com/mariadb-operator/mariadb-operator/api/v1alpha1" agentcmd "github.com/mariadb-operator/mariadb-operator/cmd/agent" backupcmd "github.com/mariadb-operator/mariadb-operator/cmd/backup" @@ -16,6 +17,7 @@ import ( condition "github.com/mariadb-operator/mariadb-operator/pkg/condition" "github.com/mariadb-operator/mariadb-operator/pkg/controller/auth" "github.com/mariadb-operator/mariadb-operator/pkg/controller/batch" + certctrl "github.com/mariadb-operator/mariadb-operator/pkg/controller/certificate" "github.com/mariadb-operator/mariadb-operator/pkg/controller/configmap" "github.com/mariadb-operator/mariadb-operator/pkg/controller/deployment" "github.com/mariadb-operator/mariadb-operator/pkg/controller/endpoints" @@ -76,6 +78,7 @@ func init() { utilruntime.Must(clientgoscheme.AddToScheme(scheme)) utilruntime.Must(mariadbv1alpha1.AddToScheme(scheme)) utilruntime.Must(monitoringv1.AddToScheme(scheme)) + utilruntime.Must(certmanagerv1.AddToScheme(scheme)) rootCmd.PersistentFlags().StringVar(&metricsAddr, "metrics-addr", ":8080", "The address the metric endpoint binds to.") rootCmd.PersistentFlags().StringVar(&healthAddr, "health-addr", ":8081", "The address the probe endpoint binds to.") @@ -205,9 +208,10 @@ var rootCmd = &cobra.Command{ authReconciler := auth.NewAuthReconciler(client, builder) deployReconciler := deployment.NewDeploymentReconciler(client) svcMonitorReconciler := servicemonitor.NewServiceMonitorReconciler(client) + certReconciler := certctrl.NewCertReconciler(client, scheme, mgr.GetEventRecorderFor("cert"), discovery, builder) mxsReconciler := maxscale.NewMaxScaleReconciler(client, builder, env) - replConfig := replication.NewReplicationConfig(client, builder, secretReconciler) + replConfig := replication.NewReplicationConfig(client, builder, secretReconciler, env) replicationReconciler, err := replication.NewReplicationReconciler( client, replRecorder, @@ -279,6 +283,7 @@ var rootCmd = &cobra.Command{ AuthReconciler: authReconciler, DeploymentReconciler: deployReconciler, ServiceMonitorReconciler: svcMonitorReconciler, + CertReconciler: certReconciler, MaxScaleReconciler: mxsReconciler, ReplicationReconciler: replicationReconciler, @@ -305,6 +310,7 @@ var rootCmd = &cobra.Command{ ServiceReconciler: serviceReconciler, DeploymentReconciler: deployReconciler, ServiceMonitorReconciler: svcMonitorReconciler, + CertReconciler: certReconciler, SuspendEnabled: featureMaxScaleSuspend, @@ -409,7 +415,7 @@ func main() { rootCmd.AddCommand(certControllerCmd) rootCmd.AddCommand(webhookCmd) rootCmd.AddCommand(backupcmd.RootCmd) - rootCmd.AddCommand(initcmd.RootCmd) + rootCmd.AddCommand(initcmd.NewInitCommand(discovery.NewDiscovery)) rootCmd.AddCommand(agentcmd.RootCmd) cobra.CheckErr(rootCmd.Execute()) diff --git a/cmd/controller/webhook.go b/cmd/controller/webhook.go index 22c335c030..467ecf6201 100644 --- a/cmd/controller/webhook.go +++ b/cmd/controller/webhook.go @@ -24,13 +24,10 @@ var ( dnsName string port int validateCert bool - - tlsCert = "tls.crt" - tlsKey = "tls.key" ) func init() { - webhookCmd.Flags().StringVar(&caCertPath, "ca-cert-path", "/tmp/k8s-webhook-server/certificate-authority/tls.crt", + webhookCmd.Flags().StringVar(&caCertPath, "ca-cert-path", filepath.Join("/tmp/k8s-webhook-server/certificate-authority", pki.TLSCertKey), "Path containing the CA TLS certificate for the webhook server.") webhookCmd.Flags().StringVar(&certDir, "cert-dir", "/tmp/k8s-webhook-server/serving-certs", "Directory containing the TLS certificate for the webhook server. 'tls.crt' and 'tls.key' must be present in this directory.") @@ -154,7 +151,7 @@ func checkCerts(dnsName string, at time.Time) error { setupLog.V(1).Info("Error reading certificate KeyPair", "error", err) return err } - valid, err := pki.ValidCert(caCert, certKeyPair, dnsName, at) + valid, err := pki.ValidateCert(caCert, certKeyPair, dnsName, at) if !valid || err != nil { err := fmt.Errorf("Certificate is not valid for %s", dnsName) setupLog.V(1).Info("Error validating certificate", "error", err) @@ -163,7 +160,7 @@ func checkCerts(dnsName string, at time.Time) error { return nil } -func readCert(certPath string) (*x509.Certificate, error) { +func readCert(certPath string) ([]*x509.Certificate, error) { if _, err := os.Stat(certPath); err != nil { return nil, err } @@ -171,15 +168,15 @@ func readCert(certPath string) (*x509.Certificate, error) { if err != nil { return nil, err } - return pki.ParseCert(certBytes) + return pki.ParseCertificates(certBytes) } func readKeyPair(dir string) (*pki.KeyPair, error) { - certFile := filepath.Join(dir, tlsCert) + certFile := filepath.Join(dir, pki.TLSCertKey) if _, err := os.Stat(certFile); err != nil { return nil, err } - keyFile := filepath.Join(dir, tlsKey) + keyFile := filepath.Join(dir, pki.TLSKeyKey) if _, err := os.Stat(certFile); err != nil { return nil, err } @@ -191,5 +188,12 @@ func readKeyPair(dir string) (*pki.KeyPair, error) { if err != nil { return nil, err } - return pki.KeyPairFromPEM(certBytes, keyBytes) + return pki.NewKeyPair( + certBytes, + keyBytes, + pki.WithSupportedPrivateKeys( + pki.PrivateKeyTypeECDSA, + pki.PrivateKeyTypeRSA, // backwards compatibility with webhook certs from previous versions + ), + ) } diff --git a/cmd/enterprise/main.go b/cmd/enterprise/main.go index 986b65764a..79d07e8cd2 100644 --- a/cmd/enterprise/main.go +++ b/cmd/enterprise/main.go @@ -7,6 +7,7 @@ import ( "syscall" "time" + certmanagerv1 "github.com/cert-manager/cert-manager/pkg/apis/certmanager/v1" mariadbv1alpha1 "github.com/mariadb-operator/mariadb-operator/api/v1alpha1" agentcmd "github.com/mariadb-operator/mariadb-operator/cmd/agent" backupcmd "github.com/mariadb-operator/mariadb-operator/cmd/backup" @@ -16,6 +17,7 @@ import ( condition "github.com/mariadb-operator/mariadb-operator/pkg/condition" "github.com/mariadb-operator/mariadb-operator/pkg/controller/auth" "github.com/mariadb-operator/mariadb-operator/pkg/controller/batch" + certctrl "github.com/mariadb-operator/mariadb-operator/pkg/controller/certificate" "github.com/mariadb-operator/mariadb-operator/pkg/controller/configmap" "github.com/mariadb-operator/mariadb-operator/pkg/controller/deployment" "github.com/mariadb-operator/mariadb-operator/pkg/controller/endpoints" @@ -82,6 +84,7 @@ func init() { utilruntime.Must(clientgoscheme.AddToScheme(scheme)) utilruntime.Must(mariadbv1alpha1.AddToScheme(scheme)) utilruntime.Must(monitoringv1.AddToScheme(scheme)) + utilruntime.Must(certmanagerv1.AddToScheme(scheme)) rootCmd.PersistentFlags().StringVar(&metricsAddr, "metrics-addr", ":8080", "The address the metric endpoint binds to.") rootCmd.PersistentFlags().StringVar(&healthAddr, "health-addr", ":8081", "The address the probe endpoint binds to.") @@ -226,9 +229,10 @@ var rootCmd = &cobra.Command{ rbacReconciler := rbac.NewRBACReconiler(client, builder) deployReconciler := deployment.NewDeploymentReconciler(client) svcMonitorReconciler := servicemonitor.NewServiceMonitorReconciler(client) + certReconciler := certctrl.NewCertReconciler(client, scheme, mgr.GetEventRecorderFor("cert"), discovery, builder) mxsReconciler := maxscale.NewMaxScaleReconciler(client, builder, env) - replConfig := replication.NewReplicationConfig(client, builder, secretReconciler) + replConfig := replication.NewReplicationConfig(client, builder, secretReconciler, env) replicationReconciler, err := replication.NewReplicationReconciler( client, replRecorder, @@ -300,6 +304,7 @@ var rootCmd = &cobra.Command{ AuthReconciler: authReconciler, DeploymentReconciler: deployReconciler, ServiceMonitorReconciler: svcMonitorReconciler, + CertReconciler: certReconciler, MaxScaleReconciler: mxsReconciler, ReplicationReconciler: replicationReconciler, @@ -326,6 +331,7 @@ var rootCmd = &cobra.Command{ ServiceReconciler: serviceReconciler, DeploymentReconciler: deployReconciler, ServiceMonitorReconciler: svcMonitorReconciler, + CertReconciler: certReconciler, SuspendEnabled: featureMaxScaleSuspend, @@ -476,7 +482,7 @@ var rootCmd = &cobra.Command{ func main() { rootCmd.AddCommand(backupcmd.RootCmd) - rootCmd.AddCommand(initcmd.RootCmd) + rootCmd.AddCommand(initcmd.NewInitCommand(discovery.NewDiscoveryEnterprise)) rootCmd.AddCommand(agentcmd.RootCmd) cobra.CheckErr(rootCmd.Execute()) diff --git a/cmd/init/main.go b/cmd/init/main.go index 651cb8fe9d..90d69dbae8 100644 --- a/cmd/init/main.go +++ b/cmd/init/main.go @@ -10,7 +10,9 @@ import ( "syscall" "time" + "github.com/go-logr/logr" mariadbv1alpha1 "github.com/mariadb-operator/mariadb-operator/api/v1alpha1" + "github.com/mariadb-operator/mariadb-operator/pkg/discovery" "github.com/mariadb-operator/mariadb-operator/pkg/environment" "github.com/mariadb-operator/mariadb-operator/pkg/galera/config" "github.com/mariadb-operator/mariadb-operator/pkg/galera/filemanager" @@ -44,88 +46,101 @@ const ( func init() { utilruntime.Must(clientgoscheme.AddToScheme(scheme)) utilruntime.Must(mariadbv1alpha1.AddToScheme(scheme)) - - RootCmd.Flags().StringVar(&configDir, "config-dir", "/etc/mysql/mariadb.conf.d", - "The directory that contains MariaDB configuration files") - RootCmd.Flags().StringVar(&stateDir, "state-dir", "/var/lib/mysql", "The directory that contains MariaDB state files") } -var RootCmd = &cobra.Command{ - Use: "init", - Short: "Init.", - Long: `Init container for Galera that co-operates with mariadb-operator.`, - Args: cobra.NoArgs, - Run: func(cmd *cobra.Command, args []string) { - if err := log.SetupLoggerWithCommand(cmd); err != nil { - fmt.Printf("error setting up logger: %v\n", err) - os.Exit(1) - } - logger.Info("Starting init") +func NewInitCommand(newDiscoveryFn discovery.NewDiscoveryFn) *cobra.Command { + command := &cobra.Command{ + Use: "init", + Short: "Init.", + Long: `Init container for Galera that co-operates with mariadb-operator.`, + Args: cobra.NoArgs, + Run: func(cmd *cobra.Command, args []string) { + if err := log.SetupLoggerWithCommand(cmd); err != nil { + fmt.Printf("error setting up logger: %v\n", err) + os.Exit(1) + } + logger.Info("Starting init") - ctx, cancel := newContext() - defer cancel() + ctx, cancel := newContext() + defer cancel() - env, err := environment.GetPodEnv(ctx) - if err != nil { - logger.Error(err, "Error getting environment variables") - os.Exit(1) - } - fileManager, err := filemanager.NewFileManager(configDir, stateDir) - if err != nil { - logger.Error(err, "Error creating file manager") - os.Exit(1) - } - state := state.NewState(stateDir) - k8sClient, err := getK8sClient() - if err != nil { - logger.Error(err, "Error getting Kubernetes client") - os.Exit(1) - } + env, err := environment.GetPodEnv(ctx) + if err != nil { + logger.Error(err, "Error getting environment variables") + os.Exit(1) + } + fileManager, err := filemanager.NewFileManager(configDir, stateDir) + if err != nil { + logger.Error(err, "Error creating file manager") + os.Exit(1) + } + state := state.NewState(stateDir) + k8sClient, err := getK8sClient() + if err != nil { + logger.Error(err, "Error getting Kubernetes client") + os.Exit(1) + } - hasGaleraState, err := state.HasGaleraState() - if err != nil { - logger.Error(err, "Error checking Galera init state") - os.Exit(1) - } - podIndex, err := statefulset.PodIndex(env.PodName) - if err != nil { - logger.Error(err, "error getting index from Pod", "pod", env.PodName) - os.Exit(1) - } + discovery, err := newDiscoveryFn() + if err != nil { + logger.Error(err, "Error creating discovery") + os.Exit(1) + } + if err := discovery.LogInfo(logger); err != nil { + logger.Error(err, "Error discovering") + os.Exit(1) + } - key := types.NamespacedName{ - Name: env.MariadbName, - Namespace: env.PodNamespace, - } - var mdb mariadbv1alpha1.MariaDB - if err := k8sClient.Get(ctx, key, &mdb); err != nil { - logger.Error(err, "Error getting MariaDB") + hasGaleraState, err := state.HasGaleraState() + if err != nil { + logger.Error(err, "Error checking Galera init state") + os.Exit(1) + } + podIndex, err := statefulset.PodIndex(env.PodName) + if err != nil { + logger.Error(err, "error getting index from Pod", "pod", env.PodName) + os.Exit(1) + } + + key := types.NamespacedName{ + Name: env.MariadbName, + Namespace: env.PodNamespace, + } + var mdb mariadbv1alpha1.MariaDB + if err := k8sClient.Get(ctx, key, &mdb); err != nil { + logger.Error(err, "Error getting MariaDB") - if err := updateGaleraConfig(fileManager, env); err != nil { - logger.Error(err, "Error updating Galera config") + if err := updateGaleraConfig(fileManager, env); err != nil { + logger.Error(err, "Error updating Galera config") + os.Exit(1) + } + logger.Info("Updated Galera config") + os.Exit(0) + } + + if err := configureGalera(fileManager, env, &mdb, discovery, logger); err != nil { + logger.Error(err, "error configuring Galera") os.Exit(1) } - logger.Info("Updated Galera config") - os.Exit(0) - } + if err := configureGaleraBootstrap(fileManager, &mdb, hasGaleraState, *podIndex); err != nil { + logger.Error(err, "error configuring Galera bootstrap") + } + if err := waitForPreviousPod(ctx, k8sClient, env, &mdb, hasGaleraState, *podIndex); err != nil { + logger.Error(err, "error waiting for previous Pod") + os.Exit(1) + } + if err := cleanupPreviousSST(fileManager); err != nil { + logger.Error(err, "error cleaning up previous SST") + os.Exit(1) + } + logger.Info("Init done") + }, + } + command.Flags().StringVar(&configDir, "config-dir", "/etc/mysql/mariadb.conf.d", + "The directory that contains MariaDB configuration files") + command.Flags().StringVar(&stateDir, "state-dir", "/var/lib/mysql", "The directory that contains MariaDB state files") - if err := configureGalera(fileManager, env, &mdb); err != nil { - logger.Error(err, "error configuring Galera") - os.Exit(1) - } - if err := configureGaleraBootstrap(fileManager, &mdb, hasGaleraState, *podIndex); err != nil { - logger.Error(err, "error configuring Galera bootstrap") - } - if err := waitForPreviousPod(ctx, k8sClient, env, &mdb, hasGaleraState, *podIndex); err != nil { - logger.Error(err, "error waiting for previous Pod") - os.Exit(1) - } - if err := cleanupPreviousSST(fileManager); err != nil { - logger.Error(err, "error cleaning up previous SST") - os.Exit(1) - } - logger.Info("Init done") - }, + return command } func newContext() (context.Context, context.CancelFunc) { @@ -150,10 +165,11 @@ func getK8sClient() (client.Client, error) { return k8sClient, nil } -func configureGalera(fm *filemanager.FileManager, env *environment.PodEnvironment, mdb *mariadbv1alpha1.MariaDB) error { +func configureGalera(fm *filemanager.FileManager, env *environment.PodEnvironment, mdb *mariadbv1alpha1.MariaDB, + discovery *discovery.Discovery, logger logr.Logger) error { logger.Info("Configuring Galera") - configBytes, err := config.NewConfigFile(mdb).Marshal(env) + configBytes, err := config.NewConfigFile(mdb, discovery, logger).Marshal(env) if err != nil { return fmt.Errorf("error getting Galera config: %v", err) } diff --git a/config/crd/bases/k8s.mariadb.com_connections.yaml b/config/crd/bases/k8s.mariadb.com_connections.yaml index 0595ba5463..d2c08009a6 100644 --- a/config/crd/bases/k8s.mariadb.com_connections.yaml +++ b/config/crd/bases/k8s.mariadb.com_connections.yaml @@ -165,6 +165,15 @@ spec: serviceName: description: ServiceName to be used in the Connection. type: string + tlsClientCertSecretRef: + description: |- + TLSClientCertSecretRef is a reference to a Kubernetes TLS Secret used as authentication when checking the connection health. + If not provided, the client certificate provided by the referred MariaDB is used. + properties: + name: + default: "" + type: string + type: object username: description: Username to use for configuring the Connection. type: string diff --git a/config/crd/bases/k8s.mariadb.com_mariadbs.yaml b/config/crd/bases/k8s.mariadb.com_mariadbs.yaml index 09534c7b2a..22b89e0151 100644 --- a/config/crd/bases/k8s.mariadb.com_mariadbs.yaml +++ b/config/crd/bases/k8s.mariadb.com_mariadbs.yaml @@ -1265,12 +1265,31 @@ spec: successThreshold: format: int32 type: integer + tcpSocket: + description: 'Refer to the Kubernetes docs: https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.31/#tcpsocketaction-v1-core.' + properties: + host: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + required: + - port + type: object timeoutSeconds: format: int32 type: integer type: object port: - description: Port where the agent will be listening for connections. + description: Port where the agent will be listening for API + connections. + format: int32 + type: integer + probePort: + description: Port where the agent will be listening for probe + connections. format: int32 type: integer readinessProbe: @@ -1316,6 +1335,19 @@ spec: successThreshold: format: int32 type: integer + tcpSocket: + description: 'Refer to the Kubernetes docs: https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.31/#tcpsocketaction-v1-core.' + properties: + host: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + required: + - port + type: object timeoutSeconds: format: int32 type: integer @@ -1681,6 +1713,19 @@ spec: successThreshold: format: int32 type: integer + tcpSocket: + description: 'Refer to the Kubernetes docs: https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.31/#tcpsocketaction-v1-core.' + properties: + host: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + required: + - port + type: object timeoutSeconds: format: int32 type: integer @@ -1728,6 +1773,19 @@ spec: successThreshold: format: int32 type: integer + tcpSocket: + description: 'Refer to the Kubernetes docs: https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.31/#tcpsocketaction-v1-core.' + properties: + host: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + required: + - port + type: object timeoutSeconds: format: int32 type: integer @@ -2221,6 +2279,19 @@ spec: successThreshold: format: int32 type: integer + tcpSocket: + description: 'Refer to the Kubernetes docs: https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.31/#tcpsocketaction-v1-core.' + properties: + host: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + required: + - port + type: object timeoutSeconds: format: int32 type: integer @@ -3406,6 +3477,123 @@ spec: - router type: object type: array + tls: + description: TLS defines the PKI to be used with MaxScale. + properties: + adminCASecretRef: + description: |- + AdminCASecretRef is a reference to a Secret containing the admin certificate authority keypair. It is used to establish trust and issue certificates for the MaxScale's administrative REST API and GUI. + One of: + - Secret containing both the 'ca.crt' and 'ca.key' keys. This allows you to bring your own CA to Kubernetes to issue certificates. + - Secret containing only the 'ca.crt' in order to establish trust. In this case, either adminCertSecretRef or adminCertIssuerRef fields must be provided. + If not provided, a self-signed CA will be provisioned to issue the server certificate. + properties: + name: + default: "" + type: string + type: object + adminCertIssuerRef: + description: |- + AdminCertIssuerRef is a reference to a cert-manager issuer object used to issue the MaxScale's administrative REST API and GUI certificate. cert-manager must be installed previously in the cluster. + It is mutually exclusive with adminCertSecretRef. + By default, the Secret field 'ca.crt' provisioned by cert-manager will be added to the trust chain. A custom trust bundle may be specified via adminCASecretRef. + properties: + group: + description: Group of the resource being referred to. + type: string + kind: + description: Kind of the resource being referred to. + type: string + name: + description: Name of the resource being referred to. + type: string + required: + - name + type: object + adminCertSecretRef: + description: AdminCertSecretRef is a reference to a TLS Secret + used by the MaxScale's administrative REST API and GUI. + properties: + name: + default: "" + type: string + type: object + enabled: + description: Enabled is a flag to enable TLS. + type: boolean + listenerCASecretRef: + description: |- + ListenerCASecretRef is a reference to a Secret containing the listener certificate authority keypair. It is used to establish trust and issue certificates for the MaxScale's listeners. + One of: + - Secret containing both the 'ca.crt' and 'ca.key' keys. This allows you to bring your own CA to Kubernetes to issue certificates. + - Secret containing only the 'ca.crt' in order to establish trust. In this case, either listenerCertSecretRef or listenerCertIssuerRef fields must be provided. + If not provided, a self-signed CA will be provisioned to issue the listener certificate. + properties: + name: + default: "" + type: string + type: object + listenerCertIssuerRef: + description: |- + ListenerCertIssuerRef is a reference to a cert-manager issuer object used to issue the MaxScale's listeners certificate. cert-manager must be installed previously in the cluster. + It is mutually exclusive with listenerCertSecretRef. + By default, the Secret field 'ca.crt' provisioned by cert-manager will be added to the trust chain. A custom trust bundle may be specified via listenerCASecretRef. + properties: + group: + description: Group of the resource being referred to. + type: string + kind: + description: Kind of the resource being referred to. + type: string + name: + description: Name of the resource being referred to. + type: string + required: + - name + type: object + listenerCertSecretRef: + description: ListenerCertSecretRef is a reference to a TLS + Secret used by the MaxScale's listeners. + properties: + name: + default: "" + type: string + type: object + replicationSSLEnabled: + description: |- + ReplicationSSLEnabled specifies whether the replication SSL is enabled. If enabled, the SSL options will be added to the server configuration. + This field is automatically set when a reference to a MariaDB via the 'mariaDbRef' field is provided. + If the MariaDB servers are manually provided by the user via the 'servers' field, this must be set by the user as well. + type: boolean + serverCASecretRef: + description: |- + ServerCASecretRef is a reference to a Secret containing the MariaDB server CA certificates. It is used to establish trust with MariaDB servers. + The Secret should contain a 'ca.crt' key in order to establish trust. + If not provided, and the reference to a MariaDB resource is set (mariaDbRef), it will be defaulted to the referred MariaDB CA bundle. + properties: + name: + default: "" + type: string + type: object + serverCertSecretRef: + description: |- + ServerCertSecretRef is a reference to a TLS Secret used by MaxScale to connect to the MariaDB servers. + If not provided, and the reference to a MariaDB resource is set (mariaDbRef), it will be defaulted to the referred MariaDB client certificate (clientCertSecretRef). + properties: + name: + default: "" + type: string + type: object + verifyPeerCertificate: + description: VerifyPeerCertificate specifies whether the peer + certificate's signature should be validated against the + CA. It is enabled by default. + type: boolean + verifyPeerHost: + description: VerifyPeerHost specifies whether the peer certificate's + SANs should match the peer host. It is disabled by default. + type: boolean + type: object updateStrategy: description: UpdateStrategy defines the update strategy for the StatefulSet object. @@ -4380,6 +4568,19 @@ spec: successThreshold: format: int32 type: integer + tcpSocket: + description: 'Refer to the Kubernetes docs: https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.31/#tcpsocketaction-v1-core.' + properties: + host: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + required: + - port + type: object timeoutSeconds: format: int32 type: integer @@ -5030,6 +5231,112 @@ spec: description: TimeZone sets the default timezone. If not provided, it defaults to SYSTEM and the timezone data is not loaded. type: string + tls: + description: TLS defines the PKI to be used with MariaDB. + properties: + clientCASecretRef: + description: |- + ClientCASecretRef is a reference to a Secret containing the client certificate authority keypair. It is used to establish trust and issue client certificates. + One of: + - Secret containing both the 'ca.crt' and 'ca.key' keys. This allows you to bring your own CA to Kubernetes to issue certificates. + - Secret containing only the 'ca.crt' in order to establish trust. In this case, either clientCertSecretRef or clientCertIssuerRef fields must be provided. + If not provided, a self-signed CA will be provisioned to issue the client certificate. + properties: + name: + default: "" + type: string + type: object + clientCertIssuerRef: + description: |- + ClientCertIssuerRef is a reference to a cert-manager issuer object used to issue the client certificate. cert-manager must be installed previously in the cluster. + It is mutually exclusive with clientCertSecretRef. + By default, the Secret field 'ca.crt' provisioned by cert-manager will be added to the trust chain. A custom trust bundle may be specified via clientCASecretRef. + properties: + group: + description: Group of the resource being referred to. + type: string + kind: + description: Kind of the resource being referred to. + type: string + name: + description: Name of the resource being referred to. + type: string + required: + - name + type: object + clientCertSecretRef: + description: |- + ClientCertSecretRef is a reference to a TLS Secret containing the client certificate. + It is mutually exclusive with clientCertIssuerRef. + properties: + name: + default: "" + type: string + type: object + enabled: + description: Enabled is a flag to enable TLS. + type: boolean + galeraClientSSLMode: + description: |- + GaleraClientSSLMode defines the client SSL mode for a Galera Enterprise cluster. + This field is only supported and applicable for Galera Enterprise >= 10.6 instances. + Refer to the MariaDB Enterprise docs for more detail: https://mariadb.com/docs/server/security/galera/#SST_TLS_Modes + enum: + - DISABLED + - REQUIRED + - VERIFY_CA + - VERIFY_IDENTITY + type: string + galeraServerSSLMode: + description: |- + GaleraServerSSLMode defines the server SSL mode for a Galera Enterprise cluster. + This field is only supported and applicable for Galera Enterprise >= 10.6 instances. + Refer to the MariaDB Enterprise docs for more detail: https://mariadb.com/docs/server/security/galera/#WSREP_TLS_Modes + enum: + - PROVIDER + - SERVER + - SERVER_X509 + type: string + serverCASecretRef: + description: |- + ServerCASecretRef is a reference to a Secret containing the server certificate authority keypair. It is used to establish trust and issue server certificates. + One of: + - Secret containing both the 'ca.crt' and 'ca.key' keys. This allows you to bring your own CA to Kubernetes to issue certificates. + - Secret containing only the 'ca.crt' in order to establish trust. In this case, either serverCertSecretRef or serverCertIssuerRef must be provided. + If not provided, a self-signed CA will be provisioned to issue the server certificate. + properties: + name: + default: "" + type: string + type: object + serverCertIssuerRef: + description: |- + ServerCertIssuerRef is a reference to a cert-manager issuer object used to issue the server certificate. cert-manager must be installed previously in the cluster. + It is mutually exclusive with serverCertSecretRef. + By default, the Secret field 'ca.crt' provisioned by cert-manager will be added to the trust chain. A custom trust bundle may be specified via serverCASecretRef. + properties: + group: + description: Group of the resource being referred to. + type: string + kind: + description: Kind of the resource being referred to. + type: string + name: + description: Name of the resource being referred to. + type: string + required: + - name + type: object + serverCertSecretRef: + description: |- + ServerCertSecretRef is a reference to a TLS Secret containing the server certificate. + It is mutually exclusive with serverCertIssuerRef. + properties: + name: + default: "" + type: string + type: object + type: object tolerations: description: Tolerations to be used in the Pod. items: @@ -5383,6 +5690,12 @@ spec: currentPrimaryPodIndex: description: CurrentPrimaryPodIndex is the primary Pod index. type: integer + defaultVersion: + description: |- + DefaultVersion is the MariaDB version used by the operator when it cannot infer the version + from spec.image. This can happen if the image uses a digest (e.g. sha256) instead + of a version tag. + type: string galeraRecovery: description: GaleraRecovery is the Galera recovery current state. properties: @@ -5445,6 +5758,85 @@ spec: description: ReplicationStatus is the replication current state for each Pod. type: object + tls: + description: TLS aggregates the status of the certificates used by + the MariaDB instance. + properties: + caBundle: + description: CABundle is the status of the Certificate Authority + bundle. + items: + description: CertificateStatus represents the current status + of a TLS certificate. + properties: + issuer: + description: Issuer is the issuer of the current certificate. + type: string + notAfter: + description: NotAfter indicates that the certificate is + not valid after the given date. + format: date-time + type: string + notBefore: + description: NotBefore indicates that the certificate is + not valid before the given date. + format: date-time + type: string + subject: + description: Subject is the subject of the current certificate. + type: string + required: + - issuer + - subject + type: object + type: array + clientCert: + description: ClientCert is the status of the client certificate. + properties: + issuer: + description: Issuer is the issuer of the current certificate. + type: string + notAfter: + description: NotAfter indicates that the certificate is not + valid after the given date. + format: date-time + type: string + notBefore: + description: NotBefore indicates that the certificate is not + valid before the given date. + format: date-time + type: string + subject: + description: Subject is the subject of the current certificate. + type: string + required: + - issuer + - subject + type: object + serverCert: + description: ServerCert is the status of the server certificate. + properties: + issuer: + description: Issuer is the issuer of the current certificate. + type: string + notAfter: + description: NotAfter indicates that the certificate is not + valid after the given date. + format: date-time + type: string + notBefore: + description: NotBefore indicates that the certificate is not + valid before the given date. + format: date-time + type: string + subject: + description: Subject is the subject of the current certificate. + type: string + required: + - issuer + - subject + type: object + type: object type: object required: - spec diff --git a/config/crd/bases/k8s.mariadb.com_maxscales.yaml b/config/crd/bases/k8s.mariadb.com_maxscales.yaml index 824497976e..b3b50ddca7 100644 --- a/config/crd/bases/k8s.mariadb.com_maxscales.yaml +++ b/config/crd/bases/k8s.mariadb.com_maxscales.yaml @@ -944,6 +944,19 @@ spec: successThreshold: format: int32 type: integer + tcpSocket: + description: 'Refer to the Kubernetes docs: https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.31/#tcpsocketaction-v1-core.' + properties: + host: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + required: + - port + type: object timeoutSeconds: format: int32 type: integer @@ -1698,6 +1711,19 @@ spec: successThreshold: format: int32 type: integer + tcpSocket: + description: 'Refer to the Kubernetes docs: https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.31/#tcpsocketaction-v1-core.' + properties: + host: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + required: + - port + type: object timeoutSeconds: format: int32 type: integer @@ -1897,6 +1923,123 @@ spec: Suspend indicates whether the current resource should be suspended or not. This can be useful for maintenance, as disabling the reconciliation prevents the operator from interfering with user operations during maintenance activities. type: boolean + tls: + description: TLS defines the PKI to be used with MaxScale. + properties: + adminCASecretRef: + description: |- + AdminCASecretRef is a reference to a Secret containing the admin certificate authority keypair. It is used to establish trust and issue certificates for the MaxScale's administrative REST API and GUI. + One of: + - Secret containing both the 'ca.crt' and 'ca.key' keys. This allows you to bring your own CA to Kubernetes to issue certificates. + - Secret containing only the 'ca.crt' in order to establish trust. In this case, either adminCertSecretRef or adminCertIssuerRef fields must be provided. + If not provided, a self-signed CA will be provisioned to issue the server certificate. + properties: + name: + default: "" + type: string + type: object + adminCertIssuerRef: + description: |- + AdminCertIssuerRef is a reference to a cert-manager issuer object used to issue the MaxScale's administrative REST API and GUI certificate. cert-manager must be installed previously in the cluster. + It is mutually exclusive with adminCertSecretRef. + By default, the Secret field 'ca.crt' provisioned by cert-manager will be added to the trust chain. A custom trust bundle may be specified via adminCASecretRef. + properties: + group: + description: Group of the resource being referred to. + type: string + kind: + description: Kind of the resource being referred to. + type: string + name: + description: Name of the resource being referred to. + type: string + required: + - name + type: object + adminCertSecretRef: + description: AdminCertSecretRef is a reference to a TLS Secret + used by the MaxScale's administrative REST API and GUI. + properties: + name: + default: "" + type: string + type: object + enabled: + description: Enabled is a flag to enable TLS. + type: boolean + listenerCASecretRef: + description: |- + ListenerCASecretRef is a reference to a Secret containing the listener certificate authority keypair. It is used to establish trust and issue certificates for the MaxScale's listeners. + One of: + - Secret containing both the 'ca.crt' and 'ca.key' keys. This allows you to bring your own CA to Kubernetes to issue certificates. + - Secret containing only the 'ca.crt' in order to establish trust. In this case, either listenerCertSecretRef or listenerCertIssuerRef fields must be provided. + If not provided, a self-signed CA will be provisioned to issue the listener certificate. + properties: + name: + default: "" + type: string + type: object + listenerCertIssuerRef: + description: |- + ListenerCertIssuerRef is a reference to a cert-manager issuer object used to issue the MaxScale's listeners certificate. cert-manager must be installed previously in the cluster. + It is mutually exclusive with listenerCertSecretRef. + By default, the Secret field 'ca.crt' provisioned by cert-manager will be added to the trust chain. A custom trust bundle may be specified via listenerCASecretRef. + properties: + group: + description: Group of the resource being referred to. + type: string + kind: + description: Kind of the resource being referred to. + type: string + name: + description: Name of the resource being referred to. + type: string + required: + - name + type: object + listenerCertSecretRef: + description: ListenerCertSecretRef is a reference to a TLS Secret + used by the MaxScale's listeners. + properties: + name: + default: "" + type: string + type: object + replicationSSLEnabled: + description: |- + ReplicationSSLEnabled specifies whether the replication SSL is enabled. If enabled, the SSL options will be added to the server configuration. + This field is automatically set when a reference to a MariaDB via the 'mariaDbRef' field is provided. + If the MariaDB servers are manually provided by the user via the 'servers' field, this must be set by the user as well. + type: boolean + serverCASecretRef: + description: |- + ServerCASecretRef is a reference to a Secret containing the MariaDB server CA certificates. It is used to establish trust with MariaDB servers. + The Secret should contain a 'ca.crt' key in order to establish trust. + If not provided, and the reference to a MariaDB resource is set (mariaDbRef), it will be defaulted to the referred MariaDB CA bundle. + properties: + name: + default: "" + type: string + type: object + serverCertSecretRef: + description: |- + ServerCertSecretRef is a reference to a TLS Secret used by MaxScale to connect to the MariaDB servers. + If not provided, and the reference to a MariaDB resource is set (mariaDbRef), it will be defaulted to the referred MariaDB client certificate (clientCertSecretRef). + properties: + name: + default: "" + type: string + type: object + verifyPeerCertificate: + description: VerifyPeerCertificate specifies whether the peer + certificate's signature should be validated against the CA. + It is enabled by default. + type: boolean + verifyPeerHost: + description: VerifyPeerHost specifies whether the peer certificate's + SANs should match the peer host. It is disabled by default. + type: boolean + type: object tolerations: description: Tolerations to be used in the Pod. items: @@ -2210,6 +2353,108 @@ spec: - state type: object type: array + tls: + description: TLS aggregates the status of the certificates used by + the MaxScale instance. + properties: + adminCert: + description: AdminCert is the status of the admin certificate. + properties: + issuer: + description: Issuer is the issuer of the current certificate. + type: string + notAfter: + description: NotAfter indicates that the certificate is not + valid after the given date. + format: date-time + type: string + notBefore: + description: NotBefore indicates that the certificate is not + valid before the given date. + format: date-time + type: string + subject: + description: Subject is the subject of the current certificate. + type: string + required: + - issuer + - subject + type: object + caBundle: + description: CABundle is the status of the Certificate Authority + bundle. + items: + description: CertificateStatus represents the current status + of a TLS certificate. + properties: + issuer: + description: Issuer is the issuer of the current certificate. + type: string + notAfter: + description: NotAfter indicates that the certificate is + not valid after the given date. + format: date-time + type: string + notBefore: + description: NotBefore indicates that the certificate is + not valid before the given date. + format: date-time + type: string + subject: + description: Subject is the subject of the current certificate. + type: string + required: + - issuer + - subject + type: object + type: array + listenerCert: + description: ListenerCert is the status of the listener certificate. + properties: + issuer: + description: Issuer is the issuer of the current certificate. + type: string + notAfter: + description: NotAfter indicates that the certificate is not + valid after the given date. + format: date-time + type: string + notBefore: + description: NotBefore indicates that the certificate is not + valid before the given date. + format: date-time + type: string + subject: + description: Subject is the subject of the current certificate. + type: string + required: + - issuer + - subject + type: object + serverCert: + description: ServerCert is the status of the MariaDB server certificate. + properties: + issuer: + description: Issuer is the issuer of the current certificate. + type: string + notAfter: + description: NotAfter indicates that the certificate is not + valid after the given date. + format: date-time + type: string + notBefore: + description: NotBefore indicates that the certificate is not + valid before the given date. + format: date-time + type: string + subject: + description: Subject is the subject of the current certificate. + type: string + required: + - issuer + - subject + type: object + type: object type: object type: object served: true diff --git a/config/crd/bases/k8s.mariadb.com_users.yaml b/config/crd/bases/k8s.mariadb.com_users.yaml index 571f1a9798..565df8a5df 100644 --- a/config/crd/bases/k8s.mariadb.com_users.yaml +++ b/config/crd/bases/k8s.mariadb.com_users.yaml @@ -157,6 +157,26 @@ spec: requeueInterval: description: RequeueInterval is used to perform requeue reconciliations. type: string + require: + description: 'Require specifies TLS requirements for the user to connect. + See: https://mariadb.com/kb/en/securing-connections-for-client-and-server/#requiring-tls.' + properties: + issuer: + description: Issuer indicates that the TLS certificate provided + by the user must be issued by a specific issuer. + type: string + ssl: + description: SSL indicates that the user must connect via TLS. + type: boolean + subject: + description: Subject indicates that the TLS certificate provided + by the user must have a specific subject. + type: string + x509: + description: X509 indicates that the user must provide a valid + x509 certificate to connect. + type: boolean + type: object retryInterval: description: RetryInterval is the interval used to perform retries. type: string diff --git a/config/manager/manager.yaml b/config/manager/manager.yaml index be577e5a79..88b6646b2d 100644 --- a/config/manager/manager.yaml +++ b/config/manager/manager.yaml @@ -56,7 +56,7 @@ spec: value: docker-registry2.mariadb.com/mariadb/mariadb-operator-enterprise:0.34.0 - name: MARIADB_GALERA_LIB_PATH value: /usr/lib64/galera/libgalera_smm.so - - name: MARIADB_ENTRYPOINT_VERSION + - name: MARIADB_DEFAULT_VERSION value: "10.6" - name: WATCH_NAMESPACE valueFrom: diff --git a/config/rbac/role.yaml b/config/rbac/role.yaml index f646924cf8..2e314fcf8f 100644 --- a/config/rbac/role.yaml +++ b/config/rbac/role.yaml @@ -115,6 +115,15 @@ rules: - list - patch - watch +- apiGroups: + - cert-manager.io + resources: + - certificates + verbs: + - create + - list + - patch + - watch - apiGroups: - k8s.mariadb.com resources: diff --git a/config/samples/backup.yaml b/config/samples/backup.yaml index f7d9e05096..b62343d9af 100644 --- a/config/samples/backup.yaml +++ b/config/samples/backup.yaml @@ -24,4 +24,4 @@ spec: enabled: true caSecretKeyRef: name: minio-ca - key: ca.crt \ No newline at end of file + key: tls.crt \ No newline at end of file diff --git a/deploy/charts/mariadb-operator-crds/templates/crds.yaml b/deploy/charts/mariadb-operator-crds/templates/crds.yaml index fba1fe8bd7..0478df4183 100644 --- a/deploy/charts/mariadb-operator-crds/templates/crds.yaml +++ b/deploy/charts/mariadb-operator-crds/templates/crds.yaml @@ -1259,6 +1259,15 @@ spec: serviceName: description: ServiceName to be used in the Connection. type: string + tlsClientCertSecretRef: + description: |- + TLSClientCertSecretRef is a reference to a Kubernetes TLS Secret used as authentication when checking the connection health. + If not provided, the client certificate provided by the referred MariaDB is used. + properties: + name: + default: "" + type: string + type: object username: description: Username to use for configuring the Connection. type: string @@ -2964,12 +2973,31 @@ spec: successThreshold: format: int32 type: integer + tcpSocket: + description: 'Refer to the Kubernetes docs: https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.31/#tcpsocketaction-v1-core.' + properties: + host: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + required: + - port + type: object timeoutSeconds: format: int32 type: integer type: object port: - description: Port where the agent will be listening for connections. + description: Port where the agent will be listening for API + connections. + format: int32 + type: integer + probePort: + description: Port where the agent will be listening for probe + connections. format: int32 type: integer readinessProbe: @@ -3015,6 +3043,19 @@ spec: successThreshold: format: int32 type: integer + tcpSocket: + description: 'Refer to the Kubernetes docs: https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.31/#tcpsocketaction-v1-core.' + properties: + host: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + required: + - port + type: object timeoutSeconds: format: int32 type: integer @@ -3380,6 +3421,19 @@ spec: successThreshold: format: int32 type: integer + tcpSocket: + description: 'Refer to the Kubernetes docs: https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.31/#tcpsocketaction-v1-core.' + properties: + host: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + required: + - port + type: object timeoutSeconds: format: int32 type: integer @@ -3427,6 +3481,19 @@ spec: successThreshold: format: int32 type: integer + tcpSocket: + description: 'Refer to the Kubernetes docs: https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.31/#tcpsocketaction-v1-core.' + properties: + host: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + required: + - port + type: object timeoutSeconds: format: int32 type: integer @@ -3920,6 +3987,19 @@ spec: successThreshold: format: int32 type: integer + tcpSocket: + description: 'Refer to the Kubernetes docs: https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.31/#tcpsocketaction-v1-core.' + properties: + host: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + required: + - port + type: object timeoutSeconds: format: int32 type: integer @@ -5105,6 +5185,123 @@ spec: - router type: object type: array + tls: + description: TLS defines the PKI to be used with MaxScale. + properties: + adminCASecretRef: + description: |- + AdminCASecretRef is a reference to a Secret containing the admin certificate authority keypair. It is used to establish trust and issue certificates for the MaxScale's administrative REST API and GUI. + One of: + - Secret containing both the 'ca.crt' and 'ca.key' keys. This allows you to bring your own CA to Kubernetes to issue certificates. + - Secret containing only the 'ca.crt' in order to establish trust. In this case, either adminCertSecretRef or adminCertIssuerRef fields must be provided. + If not provided, a self-signed CA will be provisioned to issue the server certificate. + properties: + name: + default: "" + type: string + type: object + adminCertIssuerRef: + description: |- + AdminCertIssuerRef is a reference to a cert-manager issuer object used to issue the MaxScale's administrative REST API and GUI certificate. cert-manager must be installed previously in the cluster. + It is mutually exclusive with adminCertSecretRef. + By default, the Secret field 'ca.crt' provisioned by cert-manager will be added to the trust chain. A custom trust bundle may be specified via adminCASecretRef. + properties: + group: + description: Group of the resource being referred to. + type: string + kind: + description: Kind of the resource being referred to. + type: string + name: + description: Name of the resource being referred to. + type: string + required: + - name + type: object + adminCertSecretRef: + description: AdminCertSecretRef is a reference to a TLS Secret + used by the MaxScale's administrative REST API and GUI. + properties: + name: + default: "" + type: string + type: object + enabled: + description: Enabled is a flag to enable TLS. + type: boolean + listenerCASecretRef: + description: |- + ListenerCASecretRef is a reference to a Secret containing the listener certificate authority keypair. It is used to establish trust and issue certificates for the MaxScale's listeners. + One of: + - Secret containing both the 'ca.crt' and 'ca.key' keys. This allows you to bring your own CA to Kubernetes to issue certificates. + - Secret containing only the 'ca.crt' in order to establish trust. In this case, either listenerCertSecretRef or listenerCertIssuerRef fields must be provided. + If not provided, a self-signed CA will be provisioned to issue the listener certificate. + properties: + name: + default: "" + type: string + type: object + listenerCertIssuerRef: + description: |- + ListenerCertIssuerRef is a reference to a cert-manager issuer object used to issue the MaxScale's listeners certificate. cert-manager must be installed previously in the cluster. + It is mutually exclusive with listenerCertSecretRef. + By default, the Secret field 'ca.crt' provisioned by cert-manager will be added to the trust chain. A custom trust bundle may be specified via listenerCASecretRef. + properties: + group: + description: Group of the resource being referred to. + type: string + kind: + description: Kind of the resource being referred to. + type: string + name: + description: Name of the resource being referred to. + type: string + required: + - name + type: object + listenerCertSecretRef: + description: ListenerCertSecretRef is a reference to a TLS + Secret used by the MaxScale's listeners. + properties: + name: + default: "" + type: string + type: object + replicationSSLEnabled: + description: |- + ReplicationSSLEnabled specifies whether the replication SSL is enabled. If enabled, the SSL options will be added to the server configuration. + This field is automatically set when a reference to a MariaDB via the 'mariaDbRef' field is provided. + If the MariaDB servers are manually provided by the user via the 'servers' field, this must be set by the user as well. + type: boolean + serverCASecretRef: + description: |- + ServerCASecretRef is a reference to a Secret containing the MariaDB server CA certificates. It is used to establish trust with MariaDB servers. + The Secret should contain a 'ca.crt' key in order to establish trust. + If not provided, and the reference to a MariaDB resource is set (mariaDbRef), it will be defaulted to the referred MariaDB CA bundle. + properties: + name: + default: "" + type: string + type: object + serverCertSecretRef: + description: |- + ServerCertSecretRef is a reference to a TLS Secret used by MaxScale to connect to the MariaDB servers. + If not provided, and the reference to a MariaDB resource is set (mariaDbRef), it will be defaulted to the referred MariaDB client certificate (clientCertSecretRef). + properties: + name: + default: "" + type: string + type: object + verifyPeerCertificate: + description: VerifyPeerCertificate specifies whether the peer + certificate's signature should be validated against the + CA. It is enabled by default. + type: boolean + verifyPeerHost: + description: VerifyPeerHost specifies whether the peer certificate's + SANs should match the peer host. It is disabled by default. + type: boolean + type: object updateStrategy: description: UpdateStrategy defines the update strategy for the StatefulSet object. @@ -6079,6 +6276,19 @@ spec: successThreshold: format: int32 type: integer + tcpSocket: + description: 'Refer to the Kubernetes docs: https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.31/#tcpsocketaction-v1-core.' + properties: + host: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + required: + - port + type: object timeoutSeconds: format: int32 type: integer @@ -6729,6 +6939,112 @@ spec: description: TimeZone sets the default timezone. If not provided, it defaults to SYSTEM and the timezone data is not loaded. type: string + tls: + description: TLS defines the PKI to be used with MariaDB. + properties: + clientCASecretRef: + description: |- + ClientCASecretRef is a reference to a Secret containing the client certificate authority keypair. It is used to establish trust and issue client certificates. + One of: + - Secret containing both the 'ca.crt' and 'ca.key' keys. This allows you to bring your own CA to Kubernetes to issue certificates. + - Secret containing only the 'ca.crt' in order to establish trust. In this case, either clientCertSecretRef or clientCertIssuerRef fields must be provided. + If not provided, a self-signed CA will be provisioned to issue the client certificate. + properties: + name: + default: "" + type: string + type: object + clientCertIssuerRef: + description: |- + ClientCertIssuerRef is a reference to a cert-manager issuer object used to issue the client certificate. cert-manager must be installed previously in the cluster. + It is mutually exclusive with clientCertSecretRef. + By default, the Secret field 'ca.crt' provisioned by cert-manager will be added to the trust chain. A custom trust bundle may be specified via clientCASecretRef. + properties: + group: + description: Group of the resource being referred to. + type: string + kind: + description: Kind of the resource being referred to. + type: string + name: + description: Name of the resource being referred to. + type: string + required: + - name + type: object + clientCertSecretRef: + description: |- + ClientCertSecretRef is a reference to a TLS Secret containing the client certificate. + It is mutually exclusive with clientCertIssuerRef. + properties: + name: + default: "" + type: string + type: object + enabled: + description: Enabled is a flag to enable TLS. + type: boolean + galeraClientSSLMode: + description: |- + GaleraClientSSLMode defines the client SSL mode for a Galera Enterprise cluster. + This field is only supported and applicable for Galera Enterprise >= 10.6 instances. + Refer to the MariaDB Enterprise docs for more detail: https://mariadb.com/docs/server/security/galera/#SST_TLS_Modes + enum: + - DISABLED + - REQUIRED + - VERIFY_CA + - VERIFY_IDENTITY + type: string + galeraServerSSLMode: + description: |- + GaleraServerSSLMode defines the server SSL mode for a Galera Enterprise cluster. + This field is only supported and applicable for Galera Enterprise >= 10.6 instances. + Refer to the MariaDB Enterprise docs for more detail: https://mariadb.com/docs/server/security/galera/#WSREP_TLS_Modes + enum: + - PROVIDER + - SERVER + - SERVER_X509 + type: string + serverCASecretRef: + description: |- + ServerCASecretRef is a reference to a Secret containing the server certificate authority keypair. It is used to establish trust and issue server certificates. + One of: + - Secret containing both the 'ca.crt' and 'ca.key' keys. This allows you to bring your own CA to Kubernetes to issue certificates. + - Secret containing only the 'ca.crt' in order to establish trust. In this case, either serverCertSecretRef or serverCertIssuerRef must be provided. + If not provided, a self-signed CA will be provisioned to issue the server certificate. + properties: + name: + default: "" + type: string + type: object + serverCertIssuerRef: + description: |- + ServerCertIssuerRef is a reference to a cert-manager issuer object used to issue the server certificate. cert-manager must be installed previously in the cluster. + It is mutually exclusive with serverCertSecretRef. + By default, the Secret field 'ca.crt' provisioned by cert-manager will be added to the trust chain. A custom trust bundle may be specified via serverCASecretRef. + properties: + group: + description: Group of the resource being referred to. + type: string + kind: + description: Kind of the resource being referred to. + type: string + name: + description: Name of the resource being referred to. + type: string + required: + - name + type: object + serverCertSecretRef: + description: |- + ServerCertSecretRef is a reference to a TLS Secret containing the server certificate. + It is mutually exclusive with serverCertIssuerRef. + properties: + name: + default: "" + type: string + type: object + type: object tolerations: description: Tolerations to be used in the Pod. items: @@ -7082,6 +7398,12 @@ spec: currentPrimaryPodIndex: description: CurrentPrimaryPodIndex is the primary Pod index. type: integer + defaultVersion: + description: |- + DefaultVersion is the MariaDB version used by the operator when it cannot infer the version + from spec.image. This can happen if the image uses a digest (e.g. sha256) instead + of a version tag. + type: string galeraRecovery: description: GaleraRecovery is the Galera recovery current state. properties: @@ -7144,6 +7466,85 @@ spec: description: ReplicationStatus is the replication current state for each Pod. type: object + tls: + description: TLS aggregates the status of the certificates used by + the MariaDB instance. + properties: + caBundle: + description: CABundle is the status of the Certificate Authority + bundle. + items: + description: CertificateStatus represents the current status + of a TLS certificate. + properties: + issuer: + description: Issuer is the issuer of the current certificate. + type: string + notAfter: + description: NotAfter indicates that the certificate is + not valid after the given date. + format: date-time + type: string + notBefore: + description: NotBefore indicates that the certificate is + not valid before the given date. + format: date-time + type: string + subject: + description: Subject is the subject of the current certificate. + type: string + required: + - issuer + - subject + type: object + type: array + clientCert: + description: ClientCert is the status of the client certificate. + properties: + issuer: + description: Issuer is the issuer of the current certificate. + type: string + notAfter: + description: NotAfter indicates that the certificate is not + valid after the given date. + format: date-time + type: string + notBefore: + description: NotBefore indicates that the certificate is not + valid before the given date. + format: date-time + type: string + subject: + description: Subject is the subject of the current certificate. + type: string + required: + - issuer + - subject + type: object + serverCert: + description: ServerCert is the status of the server certificate. + properties: + issuer: + description: Issuer is the issuer of the current certificate. + type: string + notAfter: + description: NotAfter indicates that the certificate is not + valid after the given date. + format: date-time + type: string + notBefore: + description: NotBefore indicates that the certificate is not + valid before the given date. + format: date-time + type: string + subject: + description: Subject is the subject of the current certificate. + type: string + required: + - issuer + - subject + type: object + type: object type: object required: - spec @@ -8101,6 +8502,19 @@ spec: successThreshold: format: int32 type: integer + tcpSocket: + description: 'Refer to the Kubernetes docs: https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.31/#tcpsocketaction-v1-core.' + properties: + host: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + required: + - port + type: object timeoutSeconds: format: int32 type: integer @@ -8855,6 +9269,19 @@ spec: successThreshold: format: int32 type: integer + tcpSocket: + description: 'Refer to the Kubernetes docs: https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.31/#tcpsocketaction-v1-core.' + properties: + host: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + required: + - port + type: object timeoutSeconds: format: int32 type: integer @@ -9054,6 +9481,123 @@ spec: Suspend indicates whether the current resource should be suspended or not. This can be useful for maintenance, as disabling the reconciliation prevents the operator from interfering with user operations during maintenance activities. type: boolean + tls: + description: TLS defines the PKI to be used with MaxScale. + properties: + adminCASecretRef: + description: |- + AdminCASecretRef is a reference to a Secret containing the admin certificate authority keypair. It is used to establish trust and issue certificates for the MaxScale's administrative REST API and GUI. + One of: + - Secret containing both the 'ca.crt' and 'ca.key' keys. This allows you to bring your own CA to Kubernetes to issue certificates. + - Secret containing only the 'ca.crt' in order to establish trust. In this case, either adminCertSecretRef or adminCertIssuerRef fields must be provided. + If not provided, a self-signed CA will be provisioned to issue the server certificate. + properties: + name: + default: "" + type: string + type: object + adminCertIssuerRef: + description: |- + AdminCertIssuerRef is a reference to a cert-manager issuer object used to issue the MaxScale's administrative REST API and GUI certificate. cert-manager must be installed previously in the cluster. + It is mutually exclusive with adminCertSecretRef. + By default, the Secret field 'ca.crt' provisioned by cert-manager will be added to the trust chain. A custom trust bundle may be specified via adminCASecretRef. + properties: + group: + description: Group of the resource being referred to. + type: string + kind: + description: Kind of the resource being referred to. + type: string + name: + description: Name of the resource being referred to. + type: string + required: + - name + type: object + adminCertSecretRef: + description: AdminCertSecretRef is a reference to a TLS Secret + used by the MaxScale's administrative REST API and GUI. + properties: + name: + default: "" + type: string + type: object + enabled: + description: Enabled is a flag to enable TLS. + type: boolean + listenerCASecretRef: + description: |- + ListenerCASecretRef is a reference to a Secret containing the listener certificate authority keypair. It is used to establish trust and issue certificates for the MaxScale's listeners. + One of: + - Secret containing both the 'ca.crt' and 'ca.key' keys. This allows you to bring your own CA to Kubernetes to issue certificates. + - Secret containing only the 'ca.crt' in order to establish trust. In this case, either listenerCertSecretRef or listenerCertIssuerRef fields must be provided. + If not provided, a self-signed CA will be provisioned to issue the listener certificate. + properties: + name: + default: "" + type: string + type: object + listenerCertIssuerRef: + description: |- + ListenerCertIssuerRef is a reference to a cert-manager issuer object used to issue the MaxScale's listeners certificate. cert-manager must be installed previously in the cluster. + It is mutually exclusive with listenerCertSecretRef. + By default, the Secret field 'ca.crt' provisioned by cert-manager will be added to the trust chain. A custom trust bundle may be specified via listenerCASecretRef. + properties: + group: + description: Group of the resource being referred to. + type: string + kind: + description: Kind of the resource being referred to. + type: string + name: + description: Name of the resource being referred to. + type: string + required: + - name + type: object + listenerCertSecretRef: + description: ListenerCertSecretRef is a reference to a TLS Secret + used by the MaxScale's listeners. + properties: + name: + default: "" + type: string + type: object + replicationSSLEnabled: + description: |- + ReplicationSSLEnabled specifies whether the replication SSL is enabled. If enabled, the SSL options will be added to the server configuration. + This field is automatically set when a reference to a MariaDB via the 'mariaDbRef' field is provided. + If the MariaDB servers are manually provided by the user via the 'servers' field, this must be set by the user as well. + type: boolean + serverCASecretRef: + description: |- + ServerCASecretRef is a reference to a Secret containing the MariaDB server CA certificates. It is used to establish trust with MariaDB servers. + The Secret should contain a 'ca.crt' key in order to establish trust. + If not provided, and the reference to a MariaDB resource is set (mariaDbRef), it will be defaulted to the referred MariaDB CA bundle. + properties: + name: + default: "" + type: string + type: object + serverCertSecretRef: + description: |- + ServerCertSecretRef is a reference to a TLS Secret used by MaxScale to connect to the MariaDB servers. + If not provided, and the reference to a MariaDB resource is set (mariaDbRef), it will be defaulted to the referred MariaDB client certificate (clientCertSecretRef). + properties: + name: + default: "" + type: string + type: object + verifyPeerCertificate: + description: VerifyPeerCertificate specifies whether the peer + certificate's signature should be validated against the CA. + It is enabled by default. + type: boolean + verifyPeerHost: + description: VerifyPeerHost specifies whether the peer certificate's + SANs should match the peer host. It is disabled by default. + type: boolean + type: object tolerations: description: Tolerations to be used in the Pod. items: @@ -9367,6 +9911,108 @@ spec: - state type: object type: array + tls: + description: TLS aggregates the status of the certificates used by + the MaxScale instance. + properties: + adminCert: + description: AdminCert is the status of the admin certificate. + properties: + issuer: + description: Issuer is the issuer of the current certificate. + type: string + notAfter: + description: NotAfter indicates that the certificate is not + valid after the given date. + format: date-time + type: string + notBefore: + description: NotBefore indicates that the certificate is not + valid before the given date. + format: date-time + type: string + subject: + description: Subject is the subject of the current certificate. + type: string + required: + - issuer + - subject + type: object + caBundle: + description: CABundle is the status of the Certificate Authority + bundle. + items: + description: CertificateStatus represents the current status + of a TLS certificate. + properties: + issuer: + description: Issuer is the issuer of the current certificate. + type: string + notAfter: + description: NotAfter indicates that the certificate is + not valid after the given date. + format: date-time + type: string + notBefore: + description: NotBefore indicates that the certificate is + not valid before the given date. + format: date-time + type: string + subject: + description: Subject is the subject of the current certificate. + type: string + required: + - issuer + - subject + type: object + type: array + listenerCert: + description: ListenerCert is the status of the listener certificate. + properties: + issuer: + description: Issuer is the issuer of the current certificate. + type: string + notAfter: + description: NotAfter indicates that the certificate is not + valid after the given date. + format: date-time + type: string + notBefore: + description: NotBefore indicates that the certificate is not + valid before the given date. + format: date-time + type: string + subject: + description: Subject is the subject of the current certificate. + type: string + required: + - issuer + - subject + type: object + serverCert: + description: ServerCert is the status of the MariaDB server certificate. + properties: + issuer: + description: Issuer is the issuer of the current certificate. + type: string + notAfter: + description: NotAfter indicates that the certificate is not + valid after the given date. + format: date-time + type: string + notBefore: + description: NotBefore indicates that the certificate is not + valid before the given date. + format: date-time + type: string + subject: + description: Subject is the subject of the current certificate. + type: string + required: + - issuer + - subject + type: object + type: object type: object type: object served: true @@ -11212,6 +11858,26 @@ spec: requeueInterval: description: RequeueInterval is used to perform requeue reconciliations. type: string + require: + description: 'Require specifies TLS requirements for the user to connect. + See: https://mariadb.com/kb/en/securing-connections-for-client-and-server/#requiring-tls.' + properties: + issuer: + description: Issuer indicates that the TLS certificate provided + by the user must be issued by a specific issuer. + type: string + ssl: + description: SSL indicates that the user must connect via TLS. + type: boolean + subject: + description: Subject indicates that the TLS certificate provided + by the user must have a specific subject. + type: string + x509: + description: X509 indicates that the user must provide a valid + x509 certificate to connect. + type: boolean + type: object retryInterval: description: RetryInterval is the interval used to perform retries. type: string diff --git a/deploy/charts/mariadb-operator/templates/cert-controller-deployment.yaml b/deploy/charts/mariadb-operator/templates/cert-controller-deployment.yaml index 6616c0cbb7..3a5d492d69 100644 --- a/deploy/charts/mariadb-operator/templates/cert-controller-deployment.yaml +++ b/deploy/charts/mariadb-operator/templates/cert-controller-deployment.yaml @@ -51,11 +51,11 @@ spec: - cert-controller - --ca-secret-name={{ include "mariadb-operator.fullname" . }}-webhook-ca - --ca-secret-namespace={{ .Release.Namespace }} - - --ca-validity={{ .Values.certController.caValidity }} + - --ca-lifetime={{ .Values.certController.caLifetime }} - --cert-secret-name={{ include "mariadb-operator.fullname" . }}-webhook-cert - --cert-secret-namespace={{ .Release.Namespace }} - - --cert-validity={{ .Values.certController.certValidity }} - - --lookahead-validity={{ .Values.certController.lookaheadValidity }} + - --cert-lifetime={{ .Values.certController.certLifetime }} + - --renew-before-percentage={{ .Values.certController.renewBeforePercentage }} - --service-name={{ include "mariadb-operator.fullname" . }}-webhook - --service-namespace={{ .Release.Namespace }} - --requeue-duration={{ .Values.certController.requeueDuration }} diff --git a/deploy/charts/mariadb-operator/templates/configmap.yaml b/deploy/charts/mariadb-operator/templates/configmap.yaml index 437478ad20..2798a713f5 100644 --- a/deploy/charts/mariadb-operator/templates/configmap.yaml +++ b/deploy/charts/mariadb-operator/templates/configmap.yaml @@ -1,6 +1,6 @@ apiVersion: v1 data: - MARIADB_ENTRYPOINT_VERSION: "11.4" + MARIADB_DEFAULT_VERSION: "11.4" MARIADB_GALERA_LIB_PATH: /usr/lib/galera/libgalera_smm.so MARIADB_OPERATOR_IMAGE: docker-registry3.mariadb.com/mariadb-operator/mariadb-operator:0.36.0 RELATED_IMAGE_EXPORTER: prom/mysqld-exporter:v0.15.1 diff --git a/deploy/charts/mariadb-operator/templates/rbac-namespace.yaml b/deploy/charts/mariadb-operator/templates/rbac-namespace.yaml index 786efc4872..ae29d41cf5 100644 --- a/deploy/charts/mariadb-operator/templates/rbac-namespace.yaml +++ b/deploy/charts/mariadb-operator/templates/rbac-namespace.yaml @@ -124,6 +124,15 @@ rules: - list - patch - watch +- apiGroups: + - cert-manager.io + resources: + - certificates + verbs: + - create + - list + - patch + - watch - apiGroups: - k8s.mariadb.com resources: diff --git a/deploy/charts/mariadb-operator/templates/rbac.yaml b/deploy/charts/mariadb-operator/templates/rbac.yaml index 7178bf2fed..fdae943d28 100644 --- a/deploy/charts/mariadb-operator/templates/rbac.yaml +++ b/deploy/charts/mariadb-operator/templates/rbac.yaml @@ -153,6 +153,15 @@ rules: - list - patch - watch +- apiGroups: + - cert-manager.io + resources: + - certificates + verbs: + - create + - list + - patch + - watch - apiGroups: - k8s.mariadb.com resources: diff --git a/deploy/charts/mariadb-operator/values.yaml b/deploy/charts/mariadb-operator/values.yaml index 6c50e93c67..9c8bc91e38 100644 --- a/deploy/charts/mariadb-operator/values.yaml +++ b/deploy/charts/mariadb-operator/values.yaml @@ -245,12 +245,12 @@ certController: enabled: false # -- Number of replicas replicas: 3 - # -- CA certificate validity. It must be greater than certValidity. - caValidity: 35064h - # -- Certificate validity. - certValidity: 8766h - # -- Duration used to verify whether a certificate is valid or not. - lookaheadValidity: 2160h + # -- CA certificate lifetime. It must be greater than certLifetime. + caLifetime: 26280h + # -- Certificate lifetime. + certLifetime: 2160h + # -- How long before the certificate expiration should the renewal process be triggered. For example, if a certificate is valid for 60 minutes, and renewBeforePercentage=25, cert-controller will begin to attempt to renew the certificate 45 minutes after it was issued (i.e. when there are 15 minutes (25%) remaining until the certificate is no longer valid). + renewBeforePercentage: 33 # -- Requeue duration to ensure that certificate gets renewed. requeueDuration: 5m serviceMonitor: diff --git a/deploy/olm/manifests/mariadb-operator-enterprise.clusterserviceversion.yaml b/deploy/olm/manifests/mariadb-operator-enterprise.clusterserviceversion.yaml index 15b7c1bb30..6d3018dec8 100644 --- a/deploy/olm/manifests/mariadb-operator-enterprise.clusterserviceversion.yaml +++ b/deploy/olm/manifests/mariadb-operator-enterprise.clusterserviceversion.yaml @@ -34,7 +34,7 @@ metadata: }, "tls": { "caSecretKeyRef": { - "key": "ca.crt", + "key": "tls.crt", "name": "minio-ca" }, "enabled": true @@ -4189,7 +4189,7 @@ spec: value: docker-registry2.mariadb.com/mariadb/mariadb-operator-enterprise@sha256:01947a1743ef6f07fe2dcb43790985cf94acc53f845730014903a841dd17f2f4 - name: MARIADB_GALERA_LIB_PATH value: /usr/lib64/galera/libgalera_smm.so - - name: MARIADB_ENTRYPOINT_VERSION + - name: MARIADB_DEFAULT_VERSION value: "10.6" - name: WATCH_NAMESPACE valueFrom: diff --git a/docs/BACKUP.md b/docs/BACKUP.md index c4d6ccceb2..fce3e398b0 100644 --- a/docs/BACKUP.md +++ b/docs/BACKUP.md @@ -78,7 +78,7 @@ spec: enabled: true caSecretKeyRef: name: minio-ca - key: ca.crt + key: tls.crt ``` By providing the authentication details and the TLS configuration via references to `Secret` keys, this example will store the backups in a local Minio instance. @@ -183,7 +183,7 @@ spec: enabled: true caSecretKeyRef: name: minio-ca - key: ca.crt + key: tls.crt ``` #### Target recovery time @@ -250,7 +250,7 @@ spec: enabled: true caSecretKeyRef: name: minio-ca - key: ca.crt + key: tls.crt targetRecoveryTime: 2023-12-19T09:00:00Z ``` @@ -523,7 +523,7 @@ spec: enabled: true caSecretKeyRef: name: minio-ca - key: ca.crt + key: tls.crt targetRecoveryTime: 2024-08-26T12:24:34Z ``` 5. If you are using Galera in your new instance, migrate your previous users and grants to use the `User` and `Grant` CRs. Refer to the [SQL resource documentation](./SQL_RESOURCES.md) for further detail. diff --git a/docs/migrations/GALERA_ENTERPRISE_MTLS.md b/docs/migrations/GALERA_ENTERPRISE_MTLS.md new file mode 100644 index 0000000000..51073d9073 --- /dev/null +++ b/docs/migrations/GALERA_ENTERPRISE_MTLS.md @@ -0,0 +1,60 @@ +## Galera Enterprise mTLS migration + +> [!IMPORTANT] +> This runbook applies to MariaDB Enterprise server version >= 10.6. Make sure you are on this version before proceeding. + +This runbook allows you to enable mTLS on an existing Galera Enterprise instance without downtime: + +- Add the following fields to your existing Galera Enterprise >= 10.6 instance: + +```diff +apiVersion: k8s.mariadb.com/v1alpha1 +kind: MariaDB +metadata: + name: mariadb-galera +spec: + galera: + enabled: true ++ providerOptions: ++ socket.dynamic: 'true' + + tls: ++ enabled: true + ++ galeraServerSSLMode: PROVIDER ++ galeraClientSSLMode: DISABLED +``` +- __[Trigger a rolling update](../UPDATES.md)__ if needed. If you use the `ReplicasFirstPrimaryLast` strategy, it will be automatically triggered by the operator +- Once the rolling update has finished, update `galeraServerSSLMode=SERVER_X509` +```diff +apiVersion: k8s.mariadb.com/v1alpha1 +kind: MariaDB +metadata: + name: mariadb-galera +spec: + tls: ++ galeraServerSSLMode: SERVER_X509 +``` +- Trigger a rolling update if needed +- Once the rolling update has finished, run __[this script](../../hack/migrate_galera_ssl_mode.sh)__ to enable `ssl_mode` on the client side: +```bash + ./hack/migrate_galera_ssl_mode.sh VERIFY_IDENTITY +``` +- Update `galeraClientSSLMode=VERIFY_IDENTITY` and remove the `socket.dynamic` provider option +```diff +apiVersion: k8s.mariadb.com/v1alpha1 +kind: MariaDB +metadata: + name: mariadb-galera +spec: + galera: + enabled: true +- providerOptions: +- socket.dynamic: 'true' + + tls: ++ galeraClientSSLMode: VERIFY_IDENTITY +``` +- Trigger a rolling update if needed + +Refer to the [MariaDB Enterprise Cluster Security docs](https://mariadb.com/docs/server/security/galera/) for further detail. \ No newline at end of file diff --git a/docs/migrations/GALERA_TLS.md b/docs/migrations/GALERA_TLS.md new file mode 100644 index 0000000000..f0ef8b2752 --- /dev/null +++ b/docs/migrations/GALERA_TLS.md @@ -0,0 +1,34 @@ +## Galera TLS migration + +This runbook allows you to enable TLS on an existing Galera instance without downtime: + +- Add the following fields to your existing Galera instance: + +```diff +apiVersion: k8s.mariadb.com/v1alpha1 +kind: MariaDB +metadata: + name: mariadb-galera +spec: + galera: + enabled: true ++ providerOptions: ++ socket.dynamic: 'true' + + tls: ++ enabled: true +``` +- __[Trigger a rolling update](../UPDATES.md)__ if needed. If you use the `ReplicasFirstPrimaryLast` strategy, it will be automatically triggered by the operator +- Once the rolling update has finished, remove the `socket.dynamic` provider option +```diff +apiVersion: k8s.mariadb.com/v1alpha1 +kind: MariaDB +metadata: + name: mariadb-galera +spec: + galera: + enabled: true +- providerOptions: +- socket.dynamic: 'true' +``` +- Trigger a rolling update if needed \ No newline at end of file diff --git a/examples/manifests/connection_maxscale_tls.yaml b/examples/manifests/connection_maxscale_tls.yaml new file mode 100644 index 0000000000..ba999d3041 --- /dev/null +++ b/examples/manifests/connection_maxscale_tls.yaml @@ -0,0 +1,17 @@ +apiVersion: k8s.mariadb.com/v1alpha1 +kind: Connection +metadata: + name: connection-maxscale +spec: + maxScaleRef: + name: maxscale-galera + username: mariadb + passwordSecretKeyRef: + name: mariadb + key: password + tlsClientCertSecretRef: + name: mariadb-galera-client-cert + database: mariadb + healthCheck: + interval: 30s + retryInterval: 3s \ No newline at end of file diff --git a/examples/manifests/connection_tls.yaml b/examples/manifests/connection_tls.yaml new file mode 100644 index 0000000000..2a27fa1b61 --- /dev/null +++ b/examples/manifests/connection_tls.yaml @@ -0,0 +1,17 @@ +apiVersion: k8s.mariadb.com/v1alpha1 +kind: Connection +metadata: + name: connection +spec: + mariaDbRef: + name: mariadb-galera + username: mariadb + passwordSecretKeyRef: + name: mariadb + key: password + tlsClientCertSecretRef: + name: mariadb-galera-client-cert + database: mariadb + healthCheck: + interval: 30s + retryInterval: 3s \ No newline at end of file diff --git a/examples/manifests/mariadb_galera_maxscale.yaml b/examples/manifests/mariadb_galera_maxscale.yaml index eb8ea8b8da..85ddba484b 100644 --- a/examples/manifests/mariadb_galera_maxscale.yaml +++ b/examples/manifests/mariadb_galera_maxscale.yaml @@ -45,6 +45,9 @@ spec: metrics: enabled: true + tls: + enabled: true + galera: enabled: true @@ -68,3 +71,6 @@ spec: metrics: enabled: true + + tls: + enabled: true \ No newline at end of file diff --git a/examples/manifests/mariadb_galera_production.yaml b/examples/manifests/mariadb_galera_production.yaml index a98251e396..1fd364c4c5 100644 --- a/examples/manifests/mariadb_galera_production.yaml +++ b/examples/manifests/mariadb_galera_production.yaml @@ -57,6 +57,9 @@ spec: metrics: enabled: true + tls: + enabled: true + updateStrategy: type: ReplicasFirstPrimaryLast # Pause updates. diff --git a/examples/manifests/mariadb_galera_tls.yaml b/examples/manifests/mariadb_galera_tls.yaml new file mode 100644 index 0000000000..a670bea109 --- /dev/null +++ b/examples/manifests/mariadb_galera_tls.yaml @@ -0,0 +1,48 @@ +apiVersion: k8s.mariadb.com/v1alpha1 +kind: MariaDB +metadata: + name: mariadb-galera +spec: + rootPasswordSecretKeyRef: + name: mariadb + key: root-password + + username: mariadb + passwordSecretKeyRef: + name: mariadb + key: password + database: mariadb + + storage: + size: 1Gi + + replicas: 3 + + galera: + enabled: true + # providerOptions: + # socket.dynamic: 'true' + + service: + type: LoadBalancer + metadata: + annotations: + metallb.universe.tf/loadBalancerIPs: 172.18.0.150 + + primaryService: + type: LoadBalancer + metadata: + annotations: + metallb.universe.tf/loadBalancerIPs: 172.18.0.160 + + secondaryService: + type: LoadBalancer + metadata: + annotations: + metallb.universe.tf/loadBalancerIPs: 172.18.0.161 + + metrics: + enabled: true + + tls: + enabled: true \ No newline at end of file diff --git a/examples/manifests/mariadb_galera_tls_bring_your_ca.yaml b/examples/manifests/mariadb_galera_tls_bring_your_ca.yaml new file mode 100644 index 0000000000..c72d95c0f0 --- /dev/null +++ b/examples/manifests/mariadb_galera_tls_bring_your_ca.yaml @@ -0,0 +1,52 @@ +apiVersion: k8s.mariadb.com/v1alpha1 +kind: MariaDB +metadata: + name: mariadb-galera +spec: + rootPasswordSecretKeyRef: + name: mariadb + key: root-password + + username: mariadb + passwordSecretKeyRef: + name: mariadb + key: password + database: mariadb + + storage: + size: 1Gi + + replicas: 3 + + galera: + enabled: true + # providerOptions: + # socket.dynamic: 'true' + + service: + type: LoadBalancer + metadata: + annotations: + metallb.universe.tf/loadBalancerIPs: 172.18.0.150 + + primaryService: + type: LoadBalancer + metadata: + annotations: + metallb.universe.tf/loadBalancerIPs: 172.18.0.160 + + secondaryService: + type: LoadBalancer + metadata: + annotations: + metallb.universe.tf/loadBalancerIPs: 172.18.0.161 + + metrics: + enabled: true + + tls: + enabled: true + serverCASecretRef: + name: mariadb-server-ca + clientCASecretRef: + name: mariadb-client-ca \ No newline at end of file diff --git a/examples/manifests/mariadb_galera_tls_cert_manager.yaml b/examples/manifests/mariadb_galera_tls_cert_manager.yaml new file mode 100644 index 0000000000..45ba1bc1a3 --- /dev/null +++ b/examples/manifests/mariadb_galera_tls_cert_manager.yaml @@ -0,0 +1,54 @@ +apiVersion: k8s.mariadb.com/v1alpha1 +kind: MariaDB +metadata: + name: mariadb-galera +spec: + rootPasswordSecretKeyRef: + name: mariadb + key: root-password + + username: mariadb + passwordSecretKeyRef: + name: mariadb + key: password + database: mariadb + + storage: + size: 1Gi + + replicas: 3 + + galera: + enabled: true + # providerOptions: + # socket.dynamic: 'true' + + service: + type: LoadBalancer + metadata: + annotations: + metallb.universe.tf/loadBalancerIPs: 172.18.0.150 + + primaryService: + type: LoadBalancer + metadata: + annotations: + metallb.universe.tf/loadBalancerIPs: 172.18.0.160 + + secondaryService: + type: LoadBalancer + metadata: + annotations: + metallb.universe.tf/loadBalancerIPs: 172.18.0.161 + + metrics: + enabled: true + + tls: + enabled: true + serverCertIssuerRef: + name: root-ca + kind: ClusterIssuer + clientCertIssuerRef: + name: root-ca + kind: ClusterIssuer \ No newline at end of file diff --git a/examples/manifests/mariadb_galera_tls_manual.yaml b/examples/manifests/mariadb_galera_tls_manual.yaml new file mode 100644 index 0000000000..bbb6de90ea --- /dev/null +++ b/examples/manifests/mariadb_galera_tls_manual.yaml @@ -0,0 +1,60 @@ +apiVersion: k8s.mariadb.com/v1alpha1 +kind: MariaDB +metadata: + name: mariadb-galera +spec: + rootPasswordSecretKeyRef: + name: mariadb + key: root-password + + username: mariadb + passwordSecretKeyRef: + name: mariadb + key: password + database: mariadb + + storage: + size: 1Gi + + replicas: 3 + + galera: + enabled: true + # providerOptions: + # socket.dynamic: 'true' + + service: + type: LoadBalancer + metadata: + annotations: + metallb.universe.tf/loadBalancerIPs: 172.18.0.150 + + primaryService: + type: LoadBalancer + metadata: + annotations: + metallb.universe.tf/loadBalancerIPs: 172.18.0.160 + + secondaryService: + type: LoadBalancer + metadata: + annotations: + metallb.universe.tf/loadBalancerIPs: 172.18.0.161 + + metrics: + enabled: true + + tls: + enabled: true + serverCASecretRef: + name: mariadb-server-ca + serverCertSecretRef: + name: mariadb-galera-server-tls + clientCASecretRef: + name: mariadb-client-ca + clientCertSecretRef: + name: mariadb-galera-client-tls + # Enterprise >= 10.6 only: + # https://mariadb.com/docs/server/security/galera/#SST_TLS_Modes + galeraServerSSLMode: SERVER_X509 # PROVIDER|SERVER|SERVER_X509 + galeraClientSSLMode: VERIFY_IDENTITY # DISABLED|REQUIRED|VERIFY_CA|VERIFY_IDENTITY \ No newline at end of file diff --git a/examples/manifests/mariadb_replication_maxscale.yaml b/examples/manifests/mariadb_replication_maxscale.yaml index b8cb9edf8a..a986911e84 100644 --- a/examples/manifests/mariadb_replication_maxscale.yaml +++ b/examples/manifests/mariadb_replication_maxscale.yaml @@ -45,6 +45,9 @@ spec: metrics: enabled: true + tls: + enabled: true + replication: enabled: true @@ -67,4 +70,7 @@ spec: metallb.universe.tf/loadBalancerIPs: 172.18.0.131 metrics: + enabled: true + + tls: enabled: true \ No newline at end of file diff --git a/examples/manifests/mariadb_replication_tls.yaml b/examples/manifests/mariadb_replication_tls.yaml new file mode 100644 index 0000000000..10f1b2b95b --- /dev/null +++ b/examples/manifests/mariadb_replication_tls.yaml @@ -0,0 +1,46 @@ +apiVersion: k8s.mariadb.com/v1alpha1 +kind: MariaDB +metadata: + name: mariadb-repl +spec: + rootPasswordSecretKeyRef: + name: mariadb + key: root-password + + username: mariadb + passwordSecretKeyRef: + name: mariadb + key: password + database: mariadb + + storage: + size: 1Gi + + replicas: 3 + + replication: + enabled: true + + service: + type: LoadBalancer + metadata: + annotations: + metallb.universe.tf/loadBalancerIPs: 172.18.0.120 + + primaryService: + type: LoadBalancer + metadata: + annotations: + metallb.universe.tf/loadBalancerIPs: 172.18.0.130 + + secondaryService: + type: LoadBalancer + metadata: + annotations: + metallb.universe.tf/loadBalancerIPs: 172.18.0.131 + + metrics: + enabled: true + + tls: + enabled: true \ No newline at end of file diff --git a/examples/manifests/mariadb_replication_tls_cert_manager.yaml b/examples/manifests/mariadb_replication_tls_cert_manager.yaml new file mode 100644 index 0000000000..5536aae908 --- /dev/null +++ b/examples/manifests/mariadb_replication_tls_cert_manager.yaml @@ -0,0 +1,52 @@ +apiVersion: k8s.mariadb.com/v1alpha1 +kind: MariaDB +metadata: + name: mariadb-repl +spec: + rootPasswordSecretKeyRef: + name: mariadb + key: root-password + + username: mariadb + passwordSecretKeyRef: + name: mariadb + key: password + database: mariadb + + storage: + size: 1Gi + + replicas: 3 + + replication: + enabled: true + + service: + type: LoadBalancer + metadata: + annotations: + metallb.universe.tf/loadBalancerIPs: 172.18.0.120 + + primaryService: + type: LoadBalancer + metadata: + annotations: + metallb.universe.tf/loadBalancerIPs: 172.18.0.130 + + secondaryService: + type: LoadBalancer + metadata: + annotations: + metallb.universe.tf/loadBalancerIPs: 172.18.0.131 + + metrics: + enabled: true + + tls: + enabled: true + serverCertIssuerRef: + name: root-ca + kind: ClusterIssuer + clientCertIssuerRef: + name: root-ca + kind: ClusterIssuer \ No newline at end of file diff --git a/examples/manifests/mariadb_replication_tls_manual.yaml b/examples/manifests/mariadb_replication_tls_manual.yaml new file mode 100644 index 0000000000..e8aecb1b8e --- /dev/null +++ b/examples/manifests/mariadb_replication_tls_manual.yaml @@ -0,0 +1,54 @@ +apiVersion: k8s.mariadb.com/v1alpha1 +kind: MariaDB +metadata: + name: mariadb-repl +spec: + rootPasswordSecretKeyRef: + name: mariadb + key: root-password + + username: mariadb + passwordSecretKeyRef: + name: mariadb + key: password + database: mariadb + + storage: + size: 1Gi + + replicas: 3 + + replication: + enabled: true + + service: + type: LoadBalancer + metadata: + annotations: + metallb.universe.tf/loadBalancerIPs: 172.18.0.120 + + primaryService: + type: LoadBalancer + metadata: + annotations: + metallb.universe.tf/loadBalancerIPs: 172.18.0.130 + + secondaryService: + type: LoadBalancer + metadata: + annotations: + metallb.universe.tf/loadBalancerIPs: 172.18.0.131 + + metrics: + enabled: true + + tls: + enabled: true + serverCASecretRef: + name: mariadb-server-ca + serverCertSecretRef: + name: mariadb-repl-server-tls + clientCASecretRef: + name: mariadb-client-ca + clientCertSecretRef: + name: mariadb-repl-client-tls \ No newline at end of file diff --git a/examples/manifests/mariadb_tls.yaml b/examples/manifests/mariadb_tls.yaml new file mode 100644 index 0000000000..99e96ce3e4 --- /dev/null +++ b/examples/manifests/mariadb_tls.yaml @@ -0,0 +1,32 @@ +apiVersion: k8s.mariadb.com/v1alpha1 +kind: MariaDB +metadata: + name: mariadb +spec: + rootPasswordSecretKeyRef: + name: mariadb + key: password + + storage: + size: 1Gi + + service: + type: LoadBalancer + metadata: + annotations: + metallb.universe.tf/loadBalancerIPs: 172.18.0.20 + + myCnf: | + [mariadb] + bind-address=* + default_storage_engine=InnoDB + binlog_format=row + innodb_autoinc_lock_mode=2 + innodb_buffer_pool_size=1024M + max_allowed_packet=256M + + metrics: + enabled: true + + tls: + enabled: true \ No newline at end of file diff --git a/examples/manifests/mariadb_tls_cert_manager.yaml b/examples/manifests/mariadb_tls_cert_manager.yaml new file mode 100644 index 0000000000..3e989fbb87 --- /dev/null +++ b/examples/manifests/mariadb_tls_cert_manager.yaml @@ -0,0 +1,38 @@ +apiVersion: k8s.mariadb.com/v1alpha1 +kind: MariaDB +metadata: + name: mariadb +spec: + rootPasswordSecretKeyRef: + name: mariadb + key: password + + storage: + size: 1Gi + + service: + type: LoadBalancer + metadata: + annotations: + metallb.universe.tf/loadBalancerIPs: 172.18.0.20 + + myCnf: | + [mariadb] + bind-address=* + default_storage_engine=InnoDB + binlog_format=row + innodb_autoinc_lock_mode=2 + innodb_buffer_pool_size=1024M + max_allowed_packet=256M + + metrics: + enabled: true + + tls: + enabled: true + serverCertIssuerRef: + name: root-ca + kind: ClusterIssuer + clientCertIssuerRef: + name: root-ca + kind: ClusterIssuer \ No newline at end of file diff --git a/examples/manifests/mariadb_tls_manual.yaml b/examples/manifests/mariadb_tls_manual.yaml new file mode 100644 index 0000000000..2a29178fab --- /dev/null +++ b/examples/manifests/mariadb_tls_manual.yaml @@ -0,0 +1,40 @@ +apiVersion: k8s.mariadb.com/v1alpha1 +kind: MariaDB +metadata: + name: mariadb +spec: + rootPasswordSecretKeyRef: + name: mariadb + key: password + + storage: + size: 1Gi + + service: + type: LoadBalancer + metadata: + annotations: + metallb.universe.tf/loadBalancerIPs: 172.18.0.20 + + myCnf: | + [mariadb] + bind-address=* + default_storage_engine=InnoDB + binlog_format=row + innodb_autoinc_lock_mode=2 + innodb_buffer_pool_size=1024M + max_allowed_packet=256M + + metrics: + enabled: true + + tls: + enabled: true + serverCASecretRef: + name: mariadb-server-ca + serverCertSecretRef: + name: mariadb-server-tls + clientCASecretRef: + name: mariadb-client-ca + clientCertSecretRef: + name: mariadb-client-tls \ No newline at end of file diff --git a/examples/manifests/maxscale_galera_tls.yaml b/examples/manifests/maxscale_galera_tls.yaml new file mode 100644 index 0000000000..beed697ef5 --- /dev/null +++ b/examples/manifests/maxscale_galera_tls.yaml @@ -0,0 +1,38 @@ +apiVersion: k8s.mariadb.com/v1alpha1 +kind: MaxScale +metadata: + name: maxscale-galera +spec: + replicas: 3 + + mariaDbRef: + name: mariadb-galera + + admin: + port: 8989 + guiEnabled: true + + auth: + generate: true + + kubernetesService: + type: LoadBalancer + metadata: + annotations: + metallb.universe.tf/loadBalancerIPs: 172.18.0.224 + + guiKubernetesService: + type: LoadBalancer + metadata: + annotations: + metallb.universe.tf/loadBalancerIPs: 172.18.0.231 + + connection: + secretName: mxs-galera-conn + port: 3306 + + metrics: + enabled: true + + tls: + enabled: true \ No newline at end of file diff --git a/examples/manifests/maxscale_galera_tls_cert_manager.yaml b/examples/manifests/maxscale_galera_tls_cert_manager.yaml new file mode 100644 index 0000000000..beed697ef5 --- /dev/null +++ b/examples/manifests/maxscale_galera_tls_cert_manager.yaml @@ -0,0 +1,38 @@ +apiVersion: k8s.mariadb.com/v1alpha1 +kind: MaxScale +metadata: + name: maxscale-galera +spec: + replicas: 3 + + mariaDbRef: + name: mariadb-galera + + admin: + port: 8989 + guiEnabled: true + + auth: + generate: true + + kubernetesService: + type: LoadBalancer + metadata: + annotations: + metallb.universe.tf/loadBalancerIPs: 172.18.0.224 + + guiKubernetesService: + type: LoadBalancer + metadata: + annotations: + metallb.universe.tf/loadBalancerIPs: 172.18.0.231 + + connection: + secretName: mxs-galera-conn + port: 3306 + + metrics: + enabled: true + + tls: + enabled: true \ No newline at end of file diff --git a/examples/manifests/maxscale_galera_tls_cert_manager_intermediate_ca.yaml b/examples/manifests/maxscale_galera_tls_cert_manager_intermediate_ca.yaml new file mode 100644 index 0000000000..bacc560f39 --- /dev/null +++ b/examples/manifests/maxscale_galera_tls_cert_manager_intermediate_ca.yaml @@ -0,0 +1,48 @@ +apiVersion: k8s.mariadb.com/v1alpha1 +kind: MaxScale +metadata: + name: maxscale-galera +spec: + replicas: 3 + + mariaDbRef: + name: mariadb-galera + + admin: + port: 8989 + guiEnabled: true + + auth: + generate: true + + kubernetesService: + type: LoadBalancer + metadata: + annotations: + metallb.universe.tf/loadBalancerIPs: 172.18.0.224 + + guiKubernetesService: + type: LoadBalancer + metadata: + annotations: + metallb.universe.tf/loadBalancerIPs: 172.18.0.231 + + connection: + secretName: mxs-galera-conn + port: 3306 + + metrics: + enabled: true + + tls: + enabled: true + adminCASecretRef: + name: root-ca + adminCertIssuerRef: + name: intermediate-ca + kind: ClusterIssuer + listenerCASecretRef: + name: root-ca + listenerCertIssuerRef: + name: intermediate-ca + kind: ClusterIssuer \ No newline at end of file diff --git a/examples/manifests/maxscale_galera_tls_manual.yaml b/examples/manifests/maxscale_galera_tls_manual.yaml new file mode 100644 index 0000000000..20cf3b1fce --- /dev/null +++ b/examples/manifests/maxscale_galera_tls_manual.yaml @@ -0,0 +1,52 @@ +apiVersion: k8s.mariadb.com/v1alpha1 +kind: MaxScale +metadata: + name: maxscale-galera +spec: + replicas: 3 + + mariaDbRef: + name: mariadb-galera + + admin: + port: 8989 + guiEnabled: true + + auth: + generate: true + + kubernetesService: + type: LoadBalancer + metadata: + annotations: + metallb.universe.tf/loadBalancerIPs: 172.18.0.224 + + guiKubernetesService: + type: LoadBalancer + metadata: + annotations: + metallb.universe.tf/loadBalancerIPs: 172.18.0.231 + + connection: + secretName: mxs-galera-conn + port: 3306 + + metrics: + enabled: true + + tls: + enabled: true + adminCASecretRef: + name: maxscale-admin-ca + adminCertSecretRef: + name: maxscale-galera-admin-tls + listenerCASecretRef: + name: maxscale-listener-ca + listenerCertSecretRef: + name: maxscale-galera-listener-tls + serverCASecretRef: + name: mariadb-galera-ca-bundle + serverCertSecretRef: + name: mariadb-galera-client-tls + verifyPeerCertificate: true + verifyPeerHost: false \ No newline at end of file diff --git a/examples/manifests/maxscale_replication_tls.yaml b/examples/manifests/maxscale_replication_tls.yaml new file mode 100644 index 0000000000..c0cc193d3d --- /dev/null +++ b/examples/manifests/maxscale_replication_tls.yaml @@ -0,0 +1,38 @@ +apiVersion: k8s.mariadb.com/v1alpha1 +kind: MaxScale +metadata: + name: maxscale-repl +spec: + replicas: 3 + + mariaDbRef: + name: mariadb-repl + + admin: + port: 8989 + guiEnabled: true + + auth: + generate: true + + kubernetesService: + type: LoadBalancer + metadata: + annotations: + metallb.universe.tf/loadBalancerIPs: 172.18.0.214 + + guiKubernetesService: + type: LoadBalancer + metadata: + annotations: + metallb.universe.tf/loadBalancerIPs: 172.18.0.230 + + connection: + secretName: mxs-galera-conn + port: 3306 + + metrics: + enabled: true + + tls: + enabled: true diff --git a/examples/manifests/maxscale_replication_tls_cert_manager.yaml b/examples/manifests/maxscale_replication_tls_cert_manager.yaml new file mode 100644 index 0000000000..1460f96fb9 --- /dev/null +++ b/examples/manifests/maxscale_replication_tls_cert_manager.yaml @@ -0,0 +1,44 @@ +apiVersion: k8s.mariadb.com/v1alpha1 +kind: MaxScale +metadata: + name: maxscale-repl +spec: + replicas: 3 + + mariaDbRef: + name: mariadb-repl + + admin: + port: 8989 + guiEnabled: true + + auth: + generate: true + + kubernetesService: + type: LoadBalancer + metadata: + annotations: + metallb.universe.tf/loadBalancerIPs: 172.18.0.214 + + guiKubernetesService: + type: LoadBalancer + metadata: + annotations: + metallb.universe.tf/loadBalancerIPs: 172.18.0.230 + + connection: + secretName: mxs-galera-conn + port: 3306 + + metrics: + enabled: true + + tls: + enabled: true + adminCertIssuerRef: + name: root-ca + kind: ClusterIssuer + listenerCertIssuerRef: + name: root-ca + kind: ClusterIssuer \ No newline at end of file diff --git a/examples/manifests/maxscale_replication_tls_manual.yaml b/examples/manifests/maxscale_replication_tls_manual.yaml new file mode 100644 index 0000000000..eb11f20cc7 --- /dev/null +++ b/examples/manifests/maxscale_replication_tls_manual.yaml @@ -0,0 +1,53 @@ +apiVersion: k8s.mariadb.com/v1alpha1 +kind: MaxScale +metadata: + name: maxscale-repl +spec: + replicas: 3 + + mariaDbRef: + name: mariadb-repl + + admin: + port: 8989 + guiEnabled: true + + auth: + generate: true + + kubernetesService: + type: LoadBalancer + metadata: + annotations: + metallb.universe.tf/loadBalancerIPs: 172.18.0.214 + + guiKubernetesService: + type: LoadBalancer + metadata: + annotations: + metallb.universe.tf/loadBalancerIPs: 172.18.0.230 + + connection: + secretName: mxs-galera-conn + port: 3306 + + metrics: + enabled: true + + tls: + enabled: true + adminCASecretRef: + name: maxscale-admin-ca + adminCertSecretRef: + name: maxscale-repl-admin-tls + listenerCASecretRef: + name: maxscale-listener-ca + listenerCertSecretRef: + name: maxscale-repl-listener-tls + serverCASecretRef: + name: mariadb-repl-ca-bundle + serverCertSecretRef: + name: mariadb-repl-client-tls + verifyPeerCertificate: true + verifyPeerHost: false + replicationSSLEnabled: true \ No newline at end of file diff --git a/examples/manifests/user_tls.yaml b/examples/manifests/user_tls.yaml new file mode 100644 index 0000000000..2ee37b2b2e --- /dev/null +++ b/examples/manifests/user_tls.yaml @@ -0,0 +1,16 @@ +apiVersion: k8s.mariadb.com/v1alpha1 +kind: User +metadata: + name: user +spec: + name: alice + mariaDbRef: + name: mariadb-galera + passwordSecretKeyRef: + name: mariadb + key: password + # see: https://mariadb.com/kb/en/securing-connections-for-client-and-server/#requiring-tls + require: + issuer: "/CN=mariadb-galera-ca" + subject: "/CN=mariadb-galera-client" + host: "%" \ No newline at end of file diff --git a/go.mod b/go.mod index 71e5e2f144..6dc67995e8 100644 --- a/go.mod +++ b/go.mod @@ -3,12 +3,14 @@ module github.com/mariadb-operator/mariadb-operator go 1.23.4 require ( + github.com/cert-manager/cert-manager v1.16.2 github.com/distribution/reference v0.6.0 github.com/dsnet/compress v0.0.1 github.com/go-chi/chi/v5 v5.2.0 github.com/go-chi/httprate v0.14.1 github.com/go-logr/logr v1.4.2 github.com/go-sql-driver/mysql v1.8.1 + github.com/google/go-cmp v0.6.0 github.com/google/uuid v1.6.0 github.com/gruntwork-io/terratest v0.48.0 github.com/hashicorp/go-multierror v1.1.1 @@ -75,8 +77,7 @@ require ( github.com/cpuguy83/go-md2man/v2 v2.0.5 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/dustin/go-humanize v1.0.1 // indirect - github.com/emicklei/go-restful/v3 v3.11.0 // indirect - github.com/evanphx/json-patch v4.12.0+incompatible // indirect + github.com/emicklei/go-restful/v3 v3.12.1 // indirect github.com/evanphx/json-patch/v5 v5.9.0 // indirect github.com/fxamacker/cbor/v2 v2.7.0 // indirect github.com/ghodss/yaml v1.0.0 // indirect @@ -97,11 +98,10 @@ require ( github.com/gonvenience/wrap v1.1.2 // indirect github.com/gonvenience/ytbx v1.4.4 // indirect github.com/google/gnostic-models v0.6.8 // indirect - github.com/google/go-cmp v0.6.0 // indirect github.com/google/gofuzz v1.2.0 // indirect github.com/google/pprof v0.0.0-20241029153458-d1b30febd7db // indirect github.com/gorilla/mux v1.8.1 // indirect - github.com/gorilla/websocket v1.5.0 // indirect + github.com/gorilla/websocket v1.5.1 // indirect github.com/gruntwork-io/go-commons v0.8.0 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect github.com/homeport/dyff v1.6.0 // indirect @@ -110,7 +110,7 @@ require ( github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/pgx/v5 v5.7.1 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect - github.com/jmespath/go-jmespath v0.4.0 // indirect + github.com/jmespath/go-jmespath v0.4.1-0.20220621161143-b0104c826a24 // indirect github.com/josharian/intern v1.0.0 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/klauspost/compress v1.17.11 // indirect @@ -118,7 +118,7 @@ require ( github.com/lucasb-eyer/go-colorful v1.2.0 // indirect github.com/mailru/easyjson v0.7.7 // indirect github.com/mattn/go-ciede2000 v0.0.0-20170301095244-782e8c62fec3 // indirect - github.com/mattn/go-isatty v0.0.19 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-zglob v0.0.2-0.20190814121620-e3c945676326 // indirect github.com/minio/md5-simd v1.1.2 // indirect github.com/mitchellh/go-homedir v1.1.0 // indirect @@ -133,7 +133,7 @@ require ( github.com/pkg/errors v0.9.1 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/pquerna/otp v1.4.0 // indirect - github.com/prometheus/client_golang v1.19.1 // indirect + github.com/prometheus/client_golang v1.20.4 // indirect github.com/prometheus/client_model v0.6.1 // indirect github.com/prometheus/common v0.55.0 // indirect github.com/prometheus/procfs v0.15.1 // indirect @@ -165,6 +165,7 @@ require ( k8s.io/apiextensions-apiserver v0.32.0 // indirect k8s.io/klog/v2 v2.130.1 // indirect k8s.io/kube-openapi v0.0.0-20241105132330-32ad38e42d3f // indirect + sigs.k8s.io/gateway-api v1.1.0 // indirect sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 // indirect sigs.k8s.io/structured-merge-diff/v4 v4.5.0 // indirect sigs.k8s.io/yaml v1.4.0 // indirect diff --git a/go.sum b/go.sum index 2e7f343f02..01e954b0ca 100644 --- a/go.sum +++ b/go.sum @@ -82,6 +82,8 @@ github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6r github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs= github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc h1:biVzkmvwrH8WK8raXaxBx6fRVTlJILwEwQGL1I/ByEI= github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8= +github.com/cert-manager/cert-manager v1.16.2 h1:c9UU2E+8XWGruyvC/mdpc1wuLddtgmNr8foKdP7a8Jg= +github.com/cert-manager/cert-manager v1.16.2/go.mod h1:MfLVTL45hFZsqmaT1O0+b2ugaNNQQZttSFV9hASHUb0= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= @@ -99,10 +101,10 @@ github.com/dsnet/compress v0.0.1/go.mod h1:Aw8dCMJ7RioblQeTqt88akK31OvO8Dhf5Jflh github.com/dsnet/golib v0.0.0-20171103203638-1ea166775780/go.mod h1:Lj+Z9rebOhdfkVLjJ8T6VcRQv3SXugXy999NBtR9aFY= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= -github.com/emicklei/go-restful/v3 v3.11.0 h1:rAQeMHw1c7zTmncogyy8VvRZwtkmkZ4FxERmMY4rD+g= -github.com/emicklei/go-restful/v3 v3.11.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= -github.com/evanphx/json-patch v4.12.0+incompatible h1:4onqiflcdA9EOZ4RxV643DvftH5pOlLGNtQ5lPWQu84= -github.com/evanphx/json-patch v4.12.0+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk= +github.com/emicklei/go-restful/v3 v3.12.1 h1:PJMDIM/ak7btuL8Ex0iYET9hxM3CI2sjZtzpL63nKAU= +github.com/emicklei/go-restful/v3 v3.12.1/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= +github.com/evanphx/json-patch v5.9.0+incompatible h1:fBXyNpNMuTTDdquAq/uisOr2lShz4oaXpDTX2bLe7ls= +github.com/evanphx/json-patch v5.9.0+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk= github.com/evanphx/json-patch/v5 v5.9.0 h1:kcBlZQbplgElYIlo/n1hJbls2z/1awpXxpRi0/FOJfg= github.com/evanphx/json-patch/v5 v5.9.0/go.mod h1:VNkHZ/282BpEyt/tObQO8s5CMPmYYq14uClGH4abBuQ= github.com/fatih/color v1.9.0/go.mod h1:eQcE1qtQxscV5RaZvpXrrb8Drkc3/DdQ+uUYCNjL+zU= @@ -167,8 +169,8 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= -github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc= -github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/gorilla/websocket v1.5.1 h1:gmztn0JnHVt9JZquRuzLw3g4wouNVzKL15iLr/zn/QY= +github.com/gorilla/websocket v1.5.1/go.mod h1:x3kM2JMyaluk02fnUJpQuwD2dCS5NDG2ZHL0uE0tcaY= github.com/gruntwork-io/go-commons v0.8.0 h1:k/yypwrPqSeYHevLlEDmvmgQzcyTwrlZGRaxEM6G0ro= github.com/gruntwork-io/go-commons v0.8.0/go.mod h1:gtp0yTtIBExIZp7vyIV9I0XQkVwiQZze678hvDXof78= github.com/gruntwork-io/terratest v0.48.0 h1:OoqJYAnBxejInn7TPizFGJNMCFvPHbiWNS3hGFKdHhA= @@ -192,8 +194,8 @@ github.com/jackc/pgx/v5 v5.7.1 h1:x7SYsPBYDkHDksogeSmZZ5xzThcTgRz++I5E+ePFUcs= github.com/jackc/pgx/v5 v5.7.1/go.mod h1:e7O26IywZZ+naJtWWos6i6fvWK+29etgITqrqHLfoZA= github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= -github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg= -github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo= +github.com/jmespath/go-jmespath v0.4.1-0.20220621161143-b0104c826a24 h1:liMMTbpW34dhU4az1GN0pTPADwNmvoRSeoZ6PItiqnY= +github.com/jmespath/go-jmespath v0.4.1-0.20220621161143-b0104c826a24/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo= github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8= github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U= github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= @@ -217,6 +219,8 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= @@ -226,8 +230,8 @@ github.com/mattn/go-ciede2000 v0.0.0-20170301095244-782e8c62fec3/go.mod h1:x1uk6 github.com/mattn/go-colorable v0.1.4/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= github.com/mattn/go-isatty v0.0.11/go.mod h1:PhnuNfih5lzO57/f3n+odYbM4JtupLOxQOAqxQCu2WE= -github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA= -github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-zglob v0.0.1/go.mod h1:9fxibJccNxU2cnpIKLRRFA7zX7qhkJIQWBb449FYHOo= github.com/mattn/go-zglob v0.0.2-0.20190814121620-e3c945676326 h1:ofNAzWCcyTALn2Zv40+8XitdzCgXY6e9qvXwN9W0YXg= github.com/mattn/go-zglob v0.0.2-0.20190814121620-e3c945676326/go.mod h1:9fxibJccNxU2cnpIKLRRFA7zX7qhkJIQWBb449FYHOo= @@ -271,8 +275,8 @@ github.com/pquerna/otp v1.4.0 h1:wZvl1TIVxKRThZIBiwOOHOGP/1+nZyWBil9Y2XNEDzg= github.com/pquerna/otp v1.4.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg= github.com/prometheus-operator/prometheus-operator/pkg/apis/monitoring v0.79.0 h1:IiCqr23V8SexkXkPmK+6tS/Ped/oCVhXSSmLacEATy4= github.com/prometheus-operator/prometheus-operator/pkg/apis/monitoring v0.79.0/go.mod h1:AVMP4QEW8xuGWnxaWSpI3kKjP9fDA31nO68zsyREJZA= -github.com/prometheus/client_golang v1.19.1 h1:wZWJDwK+NameRJuPGDhlnFgx8e8HN3XHQeLaYJFJBOE= -github.com/prometheus/client_golang v1.19.1/go.mod h1:mP78NwGzrVks5S2H6ab8+ZZGJLZUq1hoULYBAYBw1Ho= +github.com/prometheus/client_golang v1.20.4 h1:Tgh3Yr67PaOv/uTqloMsCEdeuFTatm5zIq5+qNN23vI= +github.com/prometheus/client_golang v1.20.4/go.mod h1:PIEt8X02hGcP8JWbeHyeZ53Y/jReSnHgO035n//V5WE= github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E= github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY= github.com/prometheus/common v0.55.0 h1:KEi6DK7lXW/m7Ig5i47x0vRzuBsHuvJdi5ee6Y3G1dc= @@ -419,6 +423,8 @@ k8s.io/utils v0.0.0-20241210054802-24370beab758 h1:sdbE21q2nlQtFh65saZY+rRM6x6aJ k8s.io/utils v0.0.0-20241210054802-24370beab758/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= sigs.k8s.io/controller-runtime v0.19.3 h1:XO2GvC9OPftRst6xWCpTgBZO04S2cbp0Qqkj8bX1sPw= sigs.k8s.io/controller-runtime v0.19.3/go.mod h1:j4j87DqtsThvwTv5/Tc5NFRyyF/RF0ip4+62tbTSIUM= +sigs.k8s.io/gateway-api v1.1.0 h1:DsLDXCi6jR+Xz8/xd0Z1PYl2Pn0TyaFMOPPZIj4inDM= +sigs.k8s.io/gateway-api v1.1.0/go.mod h1:ZH4lHrL2sDi0FHZ9jjneb8kKnGzFWyrTya35sWUTrRs= sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 h1:gBQPwqORJ8d8/YNZWEjoZs7npUVDpVXUUOFfW6CgAqE= sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg= sigs.k8s.io/structured-merge-diff/v4 v4.5.0 h1:nbCitCK2hfnhyiKo6uf2HxUPTCodY6Qaf85SbDIaMBk= diff --git a/hack/config/cert-manager.yaml b/hack/config/cert-manager.yaml index 9da86941dc..b7c142f52e 100644 --- a/hack/config/cert-manager.yaml +++ b/hack/config/cert-manager.yaml @@ -1,3 +1,5 @@ +clusterResourceNamespace: default + installCRDs: true prometheus: diff --git a/hack/install_cert_manager.sh b/hack/install_cert_manager.sh index 09bf3174e2..0d6d513815 100755 --- a/hack/install_cert_manager.sh +++ b/hack/install_cert_manager.sh @@ -2,7 +2,9 @@ set -eo pipefail -CONFIG="$( dirname "${BASH_SOURCE[0]}" )"/config +CURDIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )" +CONFIG="$CURDIR/config" +MANIFESTS="$CURDIR/manifests/cert-manager" if [ -z "$CERT_MANAGER_VERSION" ]; then echo "CERT_MANAGER_VERSION environment variable is mandatory" exit 1 @@ -16,3 +18,15 @@ helm upgrade --install \ -n cert-manager --create-namespace \ -f $CONFIG/cert-manager.yaml \ cert-manager jetstack/cert-manager + +kubectl apply -f "$MANIFESTS/selfsigned-clusterissuer.yaml" + +kubectl apply -f "$MANIFESTS/root-certificate.yaml" +kubectl wait --for=condition=Ready certificate root-ca --timeout=30s +kubectl apply -f "$MANIFESTS/root-clusterissuer.yaml" +kubectl wait --for=condition=Ready clusterissuer root-ca --timeout=30s + +kubectl apply -f "$MANIFESTS/intermediate-certificate.yaml" +kubectl wait --for=condition=Ready certificate intermediate-ca --timeout=30s +kubectl apply -f "$MANIFESTS/intermediate-clusterissuer.yaml" +kubectl wait --for=condition=Ready clusterissuer intermediate-ca --timeout=30s \ No newline at end of file diff --git a/hack/manifests/cert-manager/intermediate-certificate.yaml b/hack/manifests/cert-manager/intermediate-certificate.yaml new file mode 100644 index 0000000000..3504574804 --- /dev/null +++ b/hack/manifests/cert-manager/intermediate-certificate.yaml @@ -0,0 +1,25 @@ +apiVersion: cert-manager.io/v1 +kind: Certificate +metadata: + name: intermediate-ca + namespace: default +spec: + duration: 26280h # 3 years + commonName: intermediate-ca + usages: + - digital signature + - key encipherment + - cert sign + issuerRef: + name: root-ca + kind: ClusterIssuer + isCA: true + privateKey: + encoding: PKCS1 + algorithm: ECDSA + size: 256 + secretTemplate: + labels: + k8s.mariadb.com/watch: "" + secretName: intermediate-ca + revisionHistoryLimit: 10 \ No newline at end of file diff --git a/hack/manifests/cert-manager/intermediate-clusterissuer.yaml b/hack/manifests/cert-manager/intermediate-clusterissuer.yaml new file mode 100644 index 0000000000..3879facbe8 --- /dev/null +++ b/hack/manifests/cert-manager/intermediate-clusterissuer.yaml @@ -0,0 +1,7 @@ +apiVersion: cert-manager.io/v1 +kind: ClusterIssuer +metadata: + name: intermediate-ca +spec: + ca: + secretName: intermediate-ca \ No newline at end of file diff --git a/hack/manifests/cert-manager/root-certificate.yaml b/hack/manifests/cert-manager/root-certificate.yaml new file mode 100644 index 0000000000..adb434bb04 --- /dev/null +++ b/hack/manifests/cert-manager/root-certificate.yaml @@ -0,0 +1,25 @@ +apiVersion: cert-manager.io/v1 +kind: Certificate +metadata: + name: root-ca + namespace: default +spec: + duration: 52596h # 6 years + commonName: root-ca + usages: + - digital signature + - key encipherment + - cert sign + issuerRef: + name: selfsigned + kind: ClusterIssuer + isCA: true + privateKey: + encoding: PKCS1 + algorithm: ECDSA + size: 256 + secretTemplate: + labels: + k8s.mariadb.com/watch: "" + secretName: root-ca + revisionHistoryLimit: 10 \ No newline at end of file diff --git a/hack/manifests/cert-manager/root-clusterissuer.yaml b/hack/manifests/cert-manager/root-clusterissuer.yaml new file mode 100644 index 0000000000..ffb4589f76 --- /dev/null +++ b/hack/manifests/cert-manager/root-clusterissuer.yaml @@ -0,0 +1,7 @@ +apiVersion: cert-manager.io/v1 +kind: ClusterIssuer +metadata: + name: root-ca +spec: + ca: + secretName: root-ca \ No newline at end of file diff --git a/hack/manifests/cert-manager/selfsigned-clusterissuer.yaml b/hack/manifests/cert-manager/selfsigned-clusterissuer.yaml new file mode 100644 index 0000000000..2895baccc4 --- /dev/null +++ b/hack/manifests/cert-manager/selfsigned-clusterissuer.yaml @@ -0,0 +1,7 @@ +apiVersion: cert-manager.io/v1 +kind: ClusterIssuer +metadata: + name: selfsigned + namespace: default +spec: + selfSigned: {} \ No newline at end of file diff --git a/hack/manifests/metallb/mariadb-galera-service.yaml b/hack/manifests/metallb/mariadb-galera-service.yaml index b191ae87f6..a3d38f456f 100644 --- a/hack/manifests/metallb/mariadb-galera-service.yaml +++ b/hack/manifests/metallb/mariadb-galera-service.yaml @@ -15,6 +15,10 @@ spec: port: 5555 protocol: TCP targetPort: 5555 + - name: agent-probe + port: 5566 + protocol: TCP + targetPort: 5566 selector: app.kubernetes.io/instance: mariadb-galera app.kubernetes.io/name: mariadb @@ -39,6 +43,10 @@ spec: port: 5555 protocol: TCP targetPort: 5555 + - name: agent-probe + port: 5566 + protocol: TCP + targetPort: 5566 selector: app.kubernetes.io/instance: mariadb-galera app.kubernetes.io/name: mariadb @@ -63,6 +71,10 @@ spec: port: 5555 protocol: TCP targetPort: 5555 + - name: agent-probe + port: 5566 + protocol: TCP + targetPort: 5566 selector: app.kubernetes.io/instance: mariadb-galera app.kubernetes.io/name: mariadb @@ -87,6 +99,10 @@ spec: port: 5555 protocol: TCP targetPort: 5555 + - name: agent-probe + port: 5566 + protocol: TCP + targetPort: 5566 selector: app.kubernetes.io/instance: mariadb-galera app.kubernetes.io/name: mariadb diff --git a/hack/manifests/metallb/mariadb-galera-test-service.yaml b/hack/manifests/metallb/mariadb-galera-test-service.yaml index d73820f74a..e3f3df7a5b 100644 --- a/hack/manifests/metallb/mariadb-galera-test-service.yaml +++ b/hack/manifests/metallb/mariadb-galera-test-service.yaml @@ -15,6 +15,10 @@ spec: port: 5555 protocol: TCP targetPort: 5555 + - name: agent-probe + port: 5566 + protocol: TCP + targetPort: 5566 selector: app.kubernetes.io/instance: mariadb-galera-test app.kubernetes.io/name: mariadb @@ -39,6 +43,10 @@ spec: port: 5555 protocol: TCP targetPort: 5555 + - name: agent-probe + port: 5566 + protocol: TCP + targetPort: 5566 selector: app.kubernetes.io/instance: mariadb-galera-test app.kubernetes.io/name: mariadb @@ -63,6 +71,10 @@ spec: port: 5555 protocol: TCP targetPort: 5555 + - name: agent-probe + port: 5566 + protocol: TCP + targetPort: 5566 selector: app.kubernetes.io/instance: mariadb-galera-test app.kubernetes.io/name: mariadb diff --git a/hack/migrate_galera_ssl_mode.sh b/hack/migrate_galera_ssl_mode.sh new file mode 100755 index 0000000000..ee41c00818 --- /dev/null +++ b/hack/migrate_galera_ssl_mode.sh @@ -0,0 +1,17 @@ +#!/bin/bash + +set -eo pipefail + +if [ "$#" -ne 2 ]; then + echo "Usage: $0 " + exit 1 +fi + +MARIADB_INSTANCE="$1" +SSL_MODE="$2" + +for pod in $(kubectl get pods -l app.kubernetes.io/instance="$MARIADB_INSTANCE",app.kubernetes.io/name=mariadb -o jsonpath='{.items[*].metadata.name}'); do + echo "Updating ssl_mode to $SSL_MODE on pod: $pod" + kubectl exec -it "$pod" -c mariadb -- sed -i "s/^ssl_mode=.*/ssl_mode=$SSL_MODE/" /etc/mysql/mariadb.conf.d/0-galera.cnf + echo "Updated $pod successfully" +done diff --git a/internal/controller/connection_controller.go b/internal/controller/connection_controller.go index e771cbf72b..7800a89e35 100644 --- a/internal/controller/connection_controller.go +++ b/internal/controller/connection_controller.go @@ -16,16 +16,13 @@ import ( condition "github.com/mariadb-operator/mariadb-operator/pkg/condition" "github.com/mariadb-operator/mariadb-operator/pkg/controller/secret" "github.com/mariadb-operator/mariadb-operator/pkg/health" - "github.com/mariadb-operator/mariadb-operator/pkg/metadata" - "github.com/mariadb-operator/mariadb-operator/pkg/predicate" + "github.com/mariadb-operator/mariadb-operator/pkg/pki" "github.com/mariadb-operator/mariadb-operator/pkg/refresolver" clientsql "github.com/mariadb-operator/mariadb-operator/pkg/sql" - "github.com/mariadb-operator/mariadb-operator/pkg/watch" corev1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/types" ctrl "sigs.k8s.io/controller-runtime" - ctrlbuilder "sigs.k8s.io/controller-runtime/pkg/builder" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/log" ) @@ -91,39 +88,61 @@ func (r *ConnectionReconciler) Reconcile(ctx context.Context, req ctrl.Request) } func (r *ConnectionReconciler) getRefs(ctx context.Context, conn *mariadbv1alpha1.Connection) (*mariadbv1alpha1.ConnectionRefs, error) { - if conn.Spec.MariaDBRef != nil { - mdb, refErr := r.RefResolver.MariaDB(ctx, conn.Spec.MariaDBRef, conn.Namespace) + if conn.Spec.MaxScaleRef != nil { + mxs, refErr := r.RefResolver.MaxScale(ctx, conn.Spec.MaxScaleRef, conn.Namespace) if refErr != nil { var mariaDbErr *multierror.Error mariaDbErr = multierror.Append(mariaDbErr, refErr) - patchErr := r.patchStatus(ctx, conn, r.ConditionReady.PatcherRefResolver(refErr, mdb)) + patchErr := r.patchStatus(ctx, conn, r.ConditionReady.PatcherRefResolver(refErr, mxs)) mariaDbErr = multierror.Append(mariaDbErr, patchErr) - return nil, fmt.Errorf("error getting MariaDB: %v", mariaDbErr) + return nil, fmt.Errorf("error getting MaxScale: %w", mariaDbErr) } + + var mdb *mariadbv1alpha1.MariaDB + var err error + if mxs.Spec.MariaDBRef != nil { + mdb, err = r.getMariadb(ctx, mxs.Spec.MariaDBRef, conn) + if err != nil { + return nil, err + } + } + return &mariadbv1alpha1.ConnectionRefs{ - MariaDB: mdb, + MaxScale: mxs, + MariaDB: mdb, }, nil } - if conn.Spec.MaxScaleRef != nil { - mxs, refErr := r.RefResolver.MaxScale(ctx, conn.Spec.MaxScaleRef, conn.Namespace) - if refErr != nil { - var mariaDbErr *multierror.Error - mariaDbErr = multierror.Append(mariaDbErr, refErr) - patchErr := r.patchStatus(ctx, conn, r.ConditionReady.PatcherRefResolver(refErr, mxs)) - mariaDbErr = multierror.Append(mariaDbErr, patchErr) - - return nil, fmt.Errorf("error getting MaxScale: %v", mariaDbErr) + if conn.Spec.MariaDBRef != nil { + mdb, err := r.getMariadb(ctx, conn.Spec.MariaDBRef, conn) + if err != nil { + return nil, err } return &mariadbv1alpha1.ConnectionRefs{ - MaxScale: mxs, + MariaDB: mdb, }, nil } + return nil, errors.New("no references found") } +func (r *ConnectionReconciler) getMariadb(ctx context.Context, ref *mariadbv1alpha1.MariaDBRef, + conn *mariadbv1alpha1.Connection) (*mariadbv1alpha1.MariaDB, error) { + mdb, refErr := r.RefResolver.MariaDB(ctx, ref, conn.Namespace) + if refErr != nil { + var mariaDbErr *multierror.Error + mariaDbErr = multierror.Append(mariaDbErr, refErr) + + patchErr := r.patchStatus(ctx, conn, r.ConditionReady.PatcherRefResolver(refErr, mdb)) + mariaDbErr = multierror.Append(mariaDbErr, patchErr) + + return nil, fmt.Errorf("error getting MariaDB: %w", mariaDbErr) + } + return mdb, nil +} + func (r *ConnectionReconciler) waitForRefs(ctx context.Context, conn *mariadbv1alpha1.Connection, refs *mariadbv1alpha1.ConnectionRefs) (ctrl.Result, error) { if conn.Spec.MariaDBRef != nil && refs.MariaDB != nil { @@ -197,7 +216,7 @@ func (r *ConnectionReconciler) checkHealth(ctx context.Context, conn *mariadbv1a func (r *ConnectionReconciler) reconcileSecret(ctx context.Context, conn *mariadbv1alpha1.Connection, refs *mariadbv1alpha1.ConnectionRefs) error { - sqlOpts, err := r.getSqlOpts(ctx, conn) + sqlOpts, err := r.getSqlOpts(ctx, conn, refs) if err != nil { return fmt.Errorf("error getting SQL options: %v", err) } @@ -281,7 +300,8 @@ func (r *ConnectionReconciler) reconcileSecret(ctx context.Context, conn *mariad return nil } -func (r *ConnectionReconciler) getSqlOpts(ctx context.Context, conn *mariadbv1alpha1.Connection) (clientsql.Opts, error) { +func (r *ConnectionReconciler) getSqlOpts(ctx context.Context, conn *mariadbv1alpha1.Connection, + refs *mariadbv1alpha1.ConnectionRefs) (clientsql.Opts, error) { password, err := r.RefResolver.SecretKeyRef(ctx, conn.Spec.PasswordSecretKeyRef, conn.Namespace) if err != nil { return clientsql.Opts{}, fmt.Errorf("error getting password for connection DSN: %v", err) @@ -296,9 +316,81 @@ func (r *ConnectionReconciler) getSqlOpts(ctx context.Context, conn *mariadbv1al if conn.Spec.Database != nil { sqlOpts.Database = *conn.Spec.Database } + if mxs := refs.MaxScale; mxs != nil && mxs.IsTLSEnabled() { + caBundle, err := r.RefResolver.SecretKeyRef(ctx, mxs.TLSCABundleSecretKeyRef(), mxs.Namespace) + if err != nil { + return clientsql.Opts{}, fmt.Errorf("error getting MaxScale CA bundle: %v", err) + } + sqlOpts.TLSCACert = []byte(caBundle) + sqlOpts.MaxscaleName = mxs.Name + sqlOpts.Namespace = mxs.Namespace + + } else if mdb := refs.MariaDB; mdb != nil && mdb.IsTLSEnabled() { + caBundle, err := r.RefResolver.SecretKeyRef(ctx, mdb.TLSCABundleSecretKeyRef(), mdb.Namespace) + if err != nil { + return clientsql.Opts{}, fmt.Errorf("error getting MariaDB CA bundle: %v", err) + } + sqlOpts.TLSCACert = []byte(caBundle) + sqlOpts.MariadbName = mdb.Name + sqlOpts.Namespace = mdb.Namespace + } + + if err := r.addSqlClientOpts(ctx, conn, refs.MariaDB, &sqlOpts); err != nil { + return clientsql.Opts{}, fmt.Errorf("error adding SQL client opts: %v", err) + } return sqlOpts, nil } +func (r *ConnectionReconciler) addSqlClientOpts(ctx context.Context, conn *mariadbv1alpha1.Connection, mdb *mariadbv1alpha1.MariaDB, + opts *clientsql.Opts) error { + secretKey := r.clientCertSecretKey(conn, mdb) + if secretKey == nil { + return nil + } + opts.ClientName = secretKey.Name + + clientCertSelector := mariadbv1alpha1.SecretKeySelector{ + LocalObjectReference: mariadbv1alpha1.LocalObjectReference{ + Name: secretKey.Name, + }, + Key: pki.TLSCertKey, + } + clientCert, err := r.RefResolver.SecretKeyRef(ctx, clientCertSelector, secretKey.Namespace) + if err != nil { + return fmt.Errorf("error getting client certificate: %v", err) + } + opts.TLSClientCert = []byte(clientCert) + + clientPrivateKeySelector := mariadbv1alpha1.SecretKeySelector{ + LocalObjectReference: mariadbv1alpha1.LocalObjectReference{ + Name: secretKey.Name, + }, + Key: pki.TLSKeyKey, + } + clientPrivateKey, err := r.RefResolver.SecretKeyRef(ctx, clientPrivateKeySelector, secretKey.Namespace) + if err != nil { + return fmt.Errorf("error getting client certificate: %v", err) + } + opts.TLSClientPrivateKey = []byte(clientPrivateKey) + + return nil +} +func (r *ConnectionReconciler) clientCertSecretKey(conn *mariadbv1alpha1.Connection, mdb *mariadbv1alpha1.MariaDB) *types.NamespacedName { + if conn != nil && conn.Spec.TLSClientCertSecretRef != nil { + return &types.NamespacedName{ + Name: conn.Spec.TLSClientCertSecretRef.Name, + Namespace: conn.Namespace, + } + } + if mdb != nil && mdb.IsTLSEnabled() { + return &types.NamespacedName{ + Name: mdb.TLSClientCertSecretKey().Name, + Namespace: mdb.Namespace, + } + } + return nil +} + func (r *ConnectionReconciler) healthCheck(ctx context.Context, conn *mariadbv1alpha1.Connection, clientOpts clientsql.Opts) error { if conn.Spec.HealthCheck == nil { return nil @@ -367,18 +459,8 @@ func (r *ConnectionReconciler) SetupWithManager(ctx context.Context, mgr ctrl.Ma For(&mariadbv1alpha1.Connection{}). Owns(&corev1.Secret{}) - watcherIndexer := watch.NewWatcherIndexer(mgr, builder, r.Client) - if err := watcherIndexer.Watch( - ctx, - &corev1.Secret{}, - &mariadbv1alpha1.Connection{}, - &mariadbv1alpha1.ConnectionList{}, - mariadbv1alpha1.ConnectionPasswordSecretFieldPath, - ctrlbuilder.WithPredicates( - predicate.PredicateWithLabel(metadata.WatchLabel), - ), - ); err != nil { - return fmt.Errorf("error watching: %v", err) + if err := mariadbv1alpha1.IndexConnection(ctx, mgr, builder, r.Client); err != nil { + return fmt.Errorf("error indexing Connection: %v", err) } return builder.Complete(r) diff --git a/internal/controller/connection_controller_test.go b/internal/controller/connection_controller_test.go index e520feafac..bbec691662 100644 --- a/internal/controller/connection_controller_test.go +++ b/internal/controller/connection_controller_test.go @@ -77,17 +77,13 @@ var _ = Describe("Connection", func() { }, WaitForIt: true, }, - Username: testUser, - PasswordSecretKeyRef: mariadbv1alpha1.SecretKeySelector{ - LocalObjectReference: mariadbv1alpha1.LocalObjectReference{ - Name: testPwdKey.Name, - }, - Key: testPwdSecretKey, - }, - Database: &testDatabase, + Username: testUser, + PasswordSecretKeyRef: testPasswordSecretRef, + Database: &testDatabase, }, }, - "test:MariaDB11!@tcp(mdb-test.default.svc.cluster.local:3306)/test?timeout=5s&parseTime=true", + "test:MariaDB11!@tcp(mdb-test.default.svc.cluster.local:3306)/test"+ + "?timeout=5s&tls=mariadb-mdb-test-default-client-mdb-test-client-cert&parseTime=true", ), Entry( "Creating a Connection providing ServiceName", @@ -122,17 +118,36 @@ var _ = Describe("Connection", func() { }, WaitForIt: true, }, - Username: testUser, - PasswordSecretKeyRef: mariadbv1alpha1.SecretKeySelector{ - LocalObjectReference: mariadbv1alpha1.LocalObjectReference{ - Name: testPwdKey.Name, + Username: testUser, + PasswordSecretKeyRef: testPasswordSecretRef, + Database: &testDatabase, + }, + }, + "test:MariaDB11!@tcp(mdb-test.default.svc.cluster.local:3306)/test"+ + "?timeout=5s&tls=mariadb-mdb-test-default-client-mdb-test-client-cert&parseTime=true", + ), + Entry( + "Creating a Connection providing TLS client cert", + &mariadbv1alpha1.Connection{ + ObjectMeta: metav1.ObjectMeta{ + Name: "conn-tls", + Namespace: testNamespace, + }, + Spec: mariadbv1alpha1.ConnectionSpec{ + MariaDBRef: &mariadbv1alpha1.MariaDBRef{ + ObjectReference: mariadbv1alpha1.ObjectReference{ + Name: testMdbkey.Name, }, - Key: testPwdSecretKey, + WaitForIt: true, }, - Database: &testDatabase, + Username: testUser, + PasswordSecretKeyRef: testPasswordSecretRef, + TLSClientCertSecretRef: testTLSClientCertRef, + Database: &testDatabase, }, }, - "test:MariaDB11!@tcp(mdb-test.default.svc.cluster.local:3306)/test?timeout=5s&parseTime=true", + "test:MariaDB11!@tcp(mdb-test.default.svc.cluster.local:3306)/test"+ + "?timeout=5s&tls=mariadb-mdb-test-default-client-mdb-test-client-cert", ), Entry( "Creating a Connection providing DSN Format", @@ -170,14 +185,9 @@ var _ = Describe("Connection", func() { }, WaitForIt: true, }, - Username: testUser, - PasswordSecretKeyRef: mariadbv1alpha1.SecretKeySelector{ - LocalObjectReference: mariadbv1alpha1.LocalObjectReference{ - Name: testPwdKey.Name, - }, - Key: testPwdSecretKey, - }, - Database: &testDatabase, + Username: testUser, + PasswordSecretKeyRef: testPasswordSecretRef, + Database: &testDatabase, }, }, "mysql://test:MariaDB11!@mdb-test.default.svc.cluster.local:3306/test?timeout=5s", @@ -372,7 +382,8 @@ var _ = Describe("Connection", func() { return false } g.Expect(secret.Data[secretKey]).To( - BeEquivalentTo("test:MariaDB11!@tcp(mdb-test.default.svc.cluster.local:3306)/test?timeout=5s"), + BeEquivalentTo("test:MariaDB11!@tcp(mdb-test.default.svc.cluster.local:3306)/test" + + "?timeout=5s&tls=mariadb-mdb-test-default-client-mdb-test-client-cert"), ) return true }, testTimeout, testInterval).Should(BeTrue()) @@ -395,7 +406,8 @@ var _ = Describe("Connection", func() { return false } g.Expect(secret.Data[secretKey]).To( - BeEquivalentTo("updated-test:MariaDB11!@tcp(mdb-test.default.svc.cluster.local:3306)/test?timeout=5s"), + BeEquivalentTo("updated-test:MariaDB11!@tcp(mdb-test.default.svc.cluster.local:3306)/test" + + "?timeout=5s&tls=mariadb-mdb-test-default-client-mdb-test-client-cert"), ) return true }, testTimeout, testInterval).Should(BeTrue()) @@ -417,7 +429,8 @@ var _ = Describe("Connection", func() { return false } g.Expect(secret.Data[secretKey]).To( - BeEquivalentTo("updated-test:MariaDB-updated11!@tcp(mdb-test.default.svc.cluster.local:3306)/test?timeout=5s"), + BeEquivalentTo("updated-test:MariaDB-updated11!@tcp(mdb-test.default.svc.cluster.local:3306)/test" + + "?timeout=5s&tls=mariadb-mdb-test-default-client-mdb-test-client-cert"), ) return true }, testTimeout, testInterval).Should(BeTrue()) diff --git a/internal/controller/grant_controller.go b/internal/controller/grant_controller.go index 93d7ae6b55..9f3ed1d787 100644 --- a/internal/controller/grant_controller.go +++ b/internal/controller/grant_controller.go @@ -9,13 +9,9 @@ import ( "github.com/mariadb-operator/mariadb-operator/pkg/controller/sql" "github.com/mariadb-operator/mariadb-operator/pkg/refresolver" sqlClient "github.com/mariadb-operator/mariadb-operator/pkg/sql" - "github.com/mariadb-operator/mariadb-operator/pkg/watch" "k8s.io/apimachinery/pkg/types" ctrl "sigs.k8s.io/controller-runtime" - ctrlbuilder "sigs.k8s.io/controller-runtime/pkg/builder" "sigs.k8s.io/controller-runtime/pkg/client" - "sigs.k8s.io/controller-runtime/pkg/event" - "sigs.k8s.io/controller-runtime/pkg/predicate" ) // GrantReconciler reconciles a Grant object @@ -65,20 +61,8 @@ func (r *GrantReconciler) SetupWithManager(ctx context.Context, mgr ctrl.Manager builder := ctrl.NewControllerManagedBy(mgr). For(&mariadbv1alpha1.Grant{}) - watcherIndexer := watch.NewWatcherIndexer(mgr, builder, r.Client) - if err := watcherIndexer.Watch( - ctx, - &mariadbv1alpha1.User{}, - &mariadbv1alpha1.Grant{}, - &mariadbv1alpha1.GrantList{}, - mariadbv1alpha1.GrantUsernameFieldPath, - ctrlbuilder.WithPredicates(predicate.Funcs{ - CreateFunc: func(ce event.CreateEvent) bool { - return true - }, - }), - ); err != nil { - return fmt.Errorf("error watching: %v", err) + if err := mariadbv1alpha1.IndexGrant(ctx, mgr, builder, r.Client); err != nil { + return fmt.Errorf("error indexing Grant: %v", err) } return builder.Complete(r) diff --git a/internal/controller/mariadb_controller.go b/internal/controller/mariadb_controller.go index 9ec2a99e66..d3043baa82 100644 --- a/internal/controller/mariadb_controller.go +++ b/internal/controller/mariadb_controller.go @@ -3,7 +3,6 @@ package controller import ( "bytes" "context" - "crypto/sha256" "errors" "fmt" "reflect" @@ -16,6 +15,7 @@ import ( labels "github.com/mariadb-operator/mariadb-operator/pkg/builder/labels" condition "github.com/mariadb-operator/mariadb-operator/pkg/condition" "github.com/mariadb-operator/mariadb-operator/pkg/controller/auth" + certctrl "github.com/mariadb-operator/mariadb-operator/pkg/controller/certificate" "github.com/mariadb-operator/mariadb-operator/pkg/controller/configmap" "github.com/mariadb-operator/mariadb-operator/pkg/controller/deployment" "github.com/mariadb-operator/mariadb-operator/pkg/controller/endpoints" @@ -32,12 +32,9 @@ import ( "github.com/mariadb-operator/mariadb-operator/pkg/environment" "github.com/mariadb-operator/mariadb-operator/pkg/health" kadapter "github.com/mariadb-operator/mariadb-operator/pkg/kubernetes/adapter" - "github.com/mariadb-operator/mariadb-operator/pkg/metadata" mdbpod "github.com/mariadb-operator/mariadb-operator/pkg/pod" - "github.com/mariadb-operator/mariadb-operator/pkg/predicate" "github.com/mariadb-operator/mariadb-operator/pkg/refresolver" sts "github.com/mariadb-operator/mariadb-operator/pkg/statefulset" - "github.com/mariadb-operator/mariadb-operator/pkg/watch" appsv1 "k8s.io/api/apps/v1" corev1 "k8s.io/api/core/v1" policyv1 "k8s.io/api/policy/v1" @@ -50,7 +47,6 @@ import ( "k8s.io/client-go/tools/record" "k8s.io/utils/ptr" ctrl "sigs.k8s.io/controller-runtime" - ctrlbuilder "sigs.k8s.io/controller-runtime/pkg/builder" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/controller" "sigs.k8s.io/controller-runtime/pkg/log" @@ -81,10 +77,11 @@ type MariaDBReconciler struct { AuthReconciler *auth.AuthReconciler DeploymentReconciler *deployment.DeploymentReconciler ServiceMonitorReconciler *servicemonitor.ServiceMonitorReconciler - MaxScaleReconciler *maxscale.MaxScaleReconciler + CertReconciler *certctrl.CertReconciler ReplicationReconciler *replication.ReplicationReconciler GaleraReconciler *galera.GaleraReconciler + MaxScaleReconciler *maxscale.MaxScaleReconciler } type reconcilePhaseMariaDB struct { @@ -116,6 +113,7 @@ type patcherMariaDB func(*mariadbv1alpha1.MariaDBStatus) error //+kubebuilder:rbac:groups=authorization.k8s.io,resources=subjectaccessreviews,verbs=create //+kubebuilder:rbac:groups=authentication.k8s.io,resources=tokenreviews,verbs=create //+kubebuilder:rbac:groups=monitoring.coreos.com,resources=servicemonitors,verbs=list;watch;create;patch +//+kubebuilder:rbac:groups=cert-manager.io,resources=certificates,verbs=list;watch;create;patch // Reconcile is part of the main kubernetes reconciliation loop which aims to // move the current state of the cluster closer to the desired state. @@ -145,6 +143,10 @@ func (r *MariaDBReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ct Name: "ConfigMap", Reconcile: r.reconcileConfigMap, }, + { + Name: "TLS", + Reconcile: r.reconcileTLS, + }, { Name: "RBAC", Reconcile: r.reconcileRBAC, @@ -230,7 +232,7 @@ func (r *MariaDBReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ct return result, err } } - return ctrl.Result{}, nil + return requeueResult(ctx, &mariadb) } func shouldSkipPhase(err error) bool { @@ -243,6 +245,14 @@ func shouldSkipPhase(err error) bool { return false } +func requeueResult(ctx context.Context, mdb *mariadbv1alpha1.MariaDB) (ctrl.Result, error) { + if mdb.IsTLSEnabled() { + log.FromContext(ctx).V(1).Info("Requeuing MariaDB") + return ctrl.Result{RequeueAfter: 5 * time.Minute}, nil // ensure certificates get renewed + } + return ctrl.Result{}, nil +} + func (r *MariaDBReconciler) reconcileSecret(ctx context.Context, mariadb *mariadbv1alpha1.MariaDB) (ctrl.Result, error) { var secretKeyRefs []mariadbv1alpha1.GeneratedSecretKeyRef @@ -341,12 +351,12 @@ func (r *MariaDBReconciler) reconcileInit(ctx context.Context, mariadb *mariadbv func (r *MariaDBReconciler) reconcileStatefulSet(ctx context.Context, mariadb *mariadbv1alpha1.MariaDB) (ctrl.Result, error) { key := client.ObjectKeyFromObject(mariadb) - podAnnotations, err := r.getPodAnnotations(ctx, mariadb) + updateAnnotations, err := r.getUpdateAnnotations(ctx, mariadb) if err != nil { return ctrl.Result{}, fmt.Errorf("error getting Pod annotations: %v", err) } - desiredSts, err := r.Builder.BuildMariadbStatefulSet(mariadb, key, podAnnotations) + desiredSts, err := r.Builder.BuildMariadbStatefulSet(mariadb, key, updateAnnotations) if err != nil { return ctrl.Result{}, fmt.Errorf("error building StatefulSet: %v", err) } @@ -413,20 +423,6 @@ func (r *MariaDBReconciler) reconcilePodLabels(ctx context.Context, mariadb *mar return ctrl.Result{}, nil } -func (r *MariaDBReconciler) getPodAnnotations(ctx context.Context, mariadb *mariadbv1alpha1.MariaDB) (map[string]string, error) { - podAnnotations := make(map[string]string) - - if mariadb.Spec.MyCnfConfigMapKeyRef != nil { - config, err := r.RefResolver.ConfigMapKeyRef(ctx, mariadb.Spec.MyCnfConfigMapKeyRef, mariadb.Namespace) - if err != nil { - return nil, fmt.Errorf("error getting my.cnf from ConfigMap: %v", err) - } - podAnnotations[metadata.ConfigAnnotation] = fmt.Sprintf("%x", sha256.Sum256([]byte(config))) - } - - return podAnnotations, nil -} - func (r *MariaDBReconciler) reconcilePodDisruptionBudget(ctx context.Context, mariadb *mariadbv1alpha1.MariaDB) (ctrl.Result, error) { if mariadb.IsHAEnabled() && mariadb.Spec.PodDisruptionBudget == nil { return ctrl.Result{}, r.reconcileHighAvailabilityPDB(ctx, mariadb) @@ -615,6 +611,7 @@ func (r *MariaDBReconciler) reconcileInternalService(ctx context.Context, mariad ports = append(ports, kadapter.ToKubernetesSlice(mariadb.Spec.ServicePorts)...) } if mariadb.IsGaleraEnabled() { + agent := ptr.Deref(mariadb.Spec.Galera, mariadbv1alpha1.Galera{}).Agent ports = append(ports, []corev1.ServicePort{ { Name: galeraresources.GaleraClusterPortName, @@ -630,7 +627,11 @@ func (r *MariaDBReconciler) reconcileInternalService(ctx context.Context, mariad }, { Name: galeraresources.AgentPortName, - Port: ptr.Deref(mariadb.Spec.Galera, mariadbv1alpha1.Galera{}).Agent.Port, + Port: agent.Port, + }, + { + Name: galeraresources.AgentProbePortName, + Port: agent.ProbePort, }, }...) } @@ -1033,30 +1034,8 @@ func (r *MariaDBReconciler) SetupWithManager(ctx context.Context, mgr ctrl.Manag builder = builder.Owns(&rbacv1.ClusterRoleBinding{}) } - watcherIndexer := watch.NewWatcherIndexer(mgr, builder, r.Client) - if err := watcherIndexer.Watch( - ctx, - &corev1.ConfigMap{}, - &mariadbv1alpha1.MariaDB{}, - &mariadbv1alpha1.MariaDBList{}, - mariadbv1alpha1.MariadbMyCnfConfigMapFieldPath, - ctrlbuilder.WithPredicates( - predicate.PredicateWithLabel(metadata.WatchLabel), - ), - ); err != nil { - return fmt.Errorf("error watching '%s': %v", mariadbv1alpha1.MariadbMyCnfConfigMapFieldPath, err) - } - if err := watcherIndexer.Watch( - ctx, - &corev1.Secret{}, - &mariadbv1alpha1.MariaDB{}, - &mariadbv1alpha1.MariaDBList{}, - mariadbv1alpha1.MariadbMetricsPasswordSecretFieldPath, - ctrlbuilder.WithPredicates( - predicate.PredicateWithLabel(metadata.WatchLabel), - ), - ); err != nil { - return fmt.Errorf("error watching '%s': %v", mariadbv1alpha1.MariadbMetricsPasswordSecretFieldPath, err) + if err := mariadbv1alpha1.IndexMariaDB(ctx, mgr, builder, r.Client); err != nil { + return fmt.Errorf("error indexing MariaDB: %v", err) } return builder.Complete(r) diff --git a/internal/controller/mariadb_controller_galera_test.go b/internal/controller/mariadb_controller_galera_test.go index 7ea9327f43..a71294e1b0 100644 --- a/internal/controller/mariadb_controller_galera_test.go +++ b/internal/controller/mariadb_controller_galera_test.go @@ -3,6 +3,7 @@ package controller import ( "time" + cmmeta "github.com/cert-manager/cert-manager/pkg/apis/meta/v1" mariadbv1alpha1 "github.com/mariadb-operator/mariadb-operator/api/v1alpha1" "github.com/mariadb-operator/mariadb-operator/pkg/builder" labels "github.com/mariadb-operator/mariadb-operator/pkg/builder/labels" @@ -44,7 +45,7 @@ var _ = Describe("MariaDB Galera spec", func() { } Expect(k8sClient.Create(testCtx, &mdb)).To(Succeed()) DeferCleanup(func() { - deleteMariadb(key) + deleteMariadb(key, false) }) By("Expecting to eventually default") @@ -64,13 +65,14 @@ var _ = Describe("MariaDB Galera spec", func() { }) }) -var _ = Describe("MariaDB Galera basic auth", func() { - It("should reconcile", func() { +var _ = Describe("MariaDB Galera use cases", Ordered, func() { + key := types.NamespacedName{ + Name: "mariadb-galera-test", + Namespace: testNamespace, + } + + It("basic auth", func() { By("Creating MariaDB") - key := types.NamespacedName{ - Name: "mariadb-galera-test", - Namespace: testNamespace, - } mdb := &mariadbv1alpha1.MariaDB{ ObjectMeta: metav1.ObjectMeta{ Name: key.Name, @@ -85,13 +87,6 @@ var _ = Describe("MariaDB Galera basic auth", func() { Key: testPwdSecretKey, }, }, - MyCnf: ptr.To(`[mariadb] - bind-address=* - default_storage_engine=InnoDB - binlog_format=row - innodb_autoinc_lock_mode=2 - max_allowed_packet=256M - `), Galera: &mariadbv1alpha1.Galera{ Enabled: true, GaleraSpec: mariadbv1alpha1.GaleraSpec{ @@ -136,7 +131,7 @@ var _ = Describe("MariaDB Galera basic auth", func() { Expect(k8sClient.Create(testCtx, mdb)).To(Succeed()) DeferCleanup(func() { - deleteMariadb(key) + deleteMariadb(key, true) }) Eventually(func() bool { @@ -158,6 +153,82 @@ var _ = Describe("MariaDB Galera basic auth", func() { return mdb.IsReady() && mdb.HasGaleraConfiguredCondition() && mdb.HasGaleraReadyCondition() }, testHighTimeout, testInterval).Should(BeTrue()) }) + + It("TLS with cert-manager", func() { + By("Creating MariaDB") + mdb := &mariadbv1alpha1.MariaDB{ + ObjectMeta: metav1.ObjectMeta{ + Name: key.Name, + Namespace: key.Namespace, + }, + Spec: mariadbv1alpha1.MariaDBSpec{ + RootPasswordSecretKeyRef: mariadbv1alpha1.GeneratedSecretKeyRef{ + SecretKeySelector: mariadbv1alpha1.SecretKeySelector{ + LocalObjectReference: mariadbv1alpha1.LocalObjectReference{ + Name: testPwdKey.Name, + }, + Key: testPwdSecretKey, + }, + }, + Galera: &mariadbv1alpha1.Galera{ + Enabled: true, + }, + Replicas: 3, + Storage: mariadbv1alpha1.Storage{ + Size: ptr.To(resource.MustParse("300Mi")), + }, + TLS: &mariadbv1alpha1.TLS{ + Enabled: true, + ServerCertIssuerRef: &cmmeta.ObjectReference{ + Name: "root-ca", + Kind: "ClusterIssuer", + }, + ClientCertIssuerRef: &cmmeta.ObjectReference{ + Name: "root-ca", + Kind: "ClusterIssuer", + }, + }, + Service: &mariadbv1alpha1.ServiceTemplate{ + Type: corev1.ServiceTypeLoadBalancer, + Metadata: &mariadbv1alpha1.Metadata{ + Annotations: map[string]string{ + "metallb.universe.tf/loadBalancerIPs": testCidrPrefix + ".0.168", + }, + }, + }, + PrimaryService: &mariadbv1alpha1.ServiceTemplate{ + Type: corev1.ServiceTypeLoadBalancer, + Metadata: &mariadbv1alpha1.Metadata{ + Annotations: map[string]string{ + "metallb.universe.tf/loadBalancerIPs": testCidrPrefix + ".0.169", + }, + }, + }, + SecondaryService: &mariadbv1alpha1.ServiceTemplate{ + Type: corev1.ServiceTypeLoadBalancer, + Metadata: &mariadbv1alpha1.Metadata{ + Annotations: map[string]string{ + "metallb.universe.tf/loadBalancerIPs": testCidrPrefix + ".0.170", + }, + }, + }, + }, + } + applyMariadbTestConfig(mdb) + + Expect(k8sClient.Create(testCtx, mdb)).To(Succeed()) + DeferCleanup(func() { + deleteMariadb(key, true) + }) + + By("Expecting MariaDB to be ready eventually") + Eventually(func() bool { + if err := k8sClient.Get(testCtx, key, mdb); err != nil { + return false + } + return mdb.IsReady() && mdb.HasGaleraConfiguredCondition() && mdb.HasGaleraReadyCondition() + }, testHighTimeout, testInterval).Should(BeTrue()) + }) }) var _ = Describe("MariaDB Galera", Ordered, func() { @@ -243,6 +314,9 @@ var _ = Describe("MariaDB Galera", Ordered, func() { ResizeInUseVolumes: ptr.To(true), WaitForVolumeResize: ptr.To(true), }, + TLS: &mariadbv1alpha1.TLS{ + Enabled: true, + }, Service: &mariadbv1alpha1.ServiceTemplate{ Type: corev1.ServiceTypeLoadBalancer, Metadata: &mariadbv1alpha1.Metadata{ @@ -295,7 +369,7 @@ var _ = Describe("MariaDB Galera", Ordered, func() { By("Creating MariaDB Galera") Expect(k8sClient.Create(testCtx, mdb)).To(Succeed()) DeferCleanup(func() { - deleteMariadb(key) + deleteMariadb(key, false) }) }) @@ -514,6 +588,9 @@ var _ = Describe("MariaDB Galera", Ordered, func() { Generate: false, }, }, + TLS: &mariadbv1alpha1.MaxScaleTLS{ + Enabled: true, + }, Metrics: &mariadbv1alpha1.MaxScaleMetrics{ Enabled: true, }, diff --git a/internal/controller/mariadb_controller_metrics.go b/internal/controller/mariadb_controller_metrics.go index 2590453ca9..f72a809b75 100644 --- a/internal/controller/mariadb_controller_metrics.go +++ b/internal/controller/mariadb_controller_metrics.go @@ -3,7 +3,6 @@ package controller import ( "bytes" "context" - "crypto/sha256" "errors" "fmt" "time" @@ -11,9 +10,9 @@ import ( mariadbv1alpha1 "github.com/mariadb-operator/mariadb-operator/api/v1alpha1" "github.com/mariadb-operator/mariadb-operator/pkg/builder" labels "github.com/mariadb-operator/mariadb-operator/pkg/builder/labels" + builderpki "github.com/mariadb-operator/mariadb-operator/pkg/builder/pki" "github.com/mariadb-operator/mariadb-operator/pkg/controller/auth" "github.com/mariadb-operator/mariadb-operator/pkg/controller/secret" - "github.com/mariadb-operator/mariadb-operator/pkg/metadata" corev1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/types" ctrl "sigs.k8s.io/controller-runtime" @@ -123,17 +122,31 @@ func (r *MariaDBReconciler) reconcileExporterConfig(ctx context.Context, mariadb return fmt.Errorf("error getting metrics password Secret: %v", err) } - type tplOpts struct { - User string - Password string - } tpl := createTpl(secretKeyRef.Key, `[client] user={{ .User }} -password={{ .Password }}`) +password={{ .Password }} +{{- if .SSLEnabled }} +tls=true +ssl-cert={{ .SSLCert }} +ssl-key={{ .SSLKey }} +ssl-ca={{ .SSLCA }} +{{- end }} +`) buf := new(bytes.Buffer) - err = tpl.Execute(buf, tplOpts{ - User: mariadb.Spec.Metrics.Username, - Password: password, + err = tpl.Execute(buf, struct { + User string + Password string + SSLEnabled bool + SSLCert string + SSLKey string + SSLCA string + }{ + User: mariadb.Spec.Metrics.Username, + Password: password, + SSLEnabled: mariadb.IsTLSEnabled(), + SSLCert: builderpki.ClientCertPath, + SSLKey: builderpki.ClientKeyPath, + SSLCA: builderpki.CACertPath, }) if err != nil { return fmt.Errorf("error rendering exporter config: %v", err) @@ -154,7 +167,7 @@ password={{ .Password }}`) } func (r *MariaDBReconciler) reconcileExporterDeployment(ctx context.Context, mariadb *mariadbv1alpha1.MariaDB) error { - podAnnotations, err := r.getExporterPodAnnotations(ctx, mariadb) + podAnnotations, err := r.getExporterUpdateAnnotations(ctx, mariadb) if err != nil { return fmt.Errorf("error getting exporter Pod annotations: %v", err) } @@ -165,17 +178,6 @@ func (r *MariaDBReconciler) reconcileExporterDeployment(ctx context.Context, mar return r.DeploymentReconciler.Reconcile(ctx, desiredDeploy) } -func (r *MariaDBReconciler) getExporterPodAnnotations(ctx context.Context, mdb *mariadbv1alpha1.MariaDB) (map[string]string, error) { - config, err := r.RefResolver.SecretKeyRef(ctx, mdb.MetricsConfigSecretKeyRef().SecretKeySelector, mdb.Namespace) - if err != nil { - return nil, fmt.Errorf("error getting metrics config Secret: %v", err) - } - podAnnotations := map[string]string{ - metadata.ConfigAnnotation: fmt.Sprintf("%x", sha256.Sum256([]byte(config))), - } - return podAnnotations, nil -} - func (r *MariaDBReconciler) reconcileExporterService(ctx context.Context, mariadb *mariadbv1alpha1.MariaDB) error { key := mariadb.MetricsKey() selectorLabels := diff --git a/internal/controller/mariadb_controller_replication_test.go b/internal/controller/mariadb_controller_replication_test.go index c781ae64b3..bd88e03a16 100644 --- a/internal/controller/mariadb_controller_replication_test.go +++ b/internal/controller/mariadb_controller_replication_test.go @@ -70,6 +70,9 @@ var _ = Describe("MariaDB replication", Ordered, func() { ResizeInUseVolumes: ptr.To(true), WaitForVolumeResize: ptr.To(true), }, + TLS: &mariadbv1alpha1.TLS{ + Enabled: true, + }, Service: &mariadbv1alpha1.ServiceTemplate{ Type: corev1.ServiceTypeLoadBalancer, Metadata: &mariadbv1alpha1.Metadata{ @@ -131,7 +134,7 @@ var _ = Describe("MariaDB replication", Ordered, func() { By("Creating MariaDB with replication") Expect(k8sClient.Create(testCtx, mdb)).To(Succeed()) DeferCleanup(func() { - deleteMariadb(key) + deleteMariadb(key, false) }) }) @@ -356,6 +359,9 @@ var _ = Describe("MariaDB replication", Ordered, func() { Generate: false, }, }, + TLS: &mariadbv1alpha1.MaxScaleTLS{ + Enabled: true, + }, Metrics: &mariadbv1alpha1.MaxScaleMetrics{ Enabled: true, }, diff --git a/internal/controller/mariadb_controller_status.go b/internal/controller/mariadb_controller_status.go index a1c177c10b..c84beba3fa 100644 --- a/internal/controller/mariadb_controller_status.go +++ b/internal/controller/mariadb_controller_status.go @@ -30,22 +30,30 @@ func (r *MariaDBReconciler) reconcileStatus(ctx context.Context, mdb *mariadbv1a return nil }) } + logger := log.FromContext(ctx).WithName("status").V(1) var sts appsv1.StatefulSet if err := r.Get(ctx, client.ObjectKeyFromObject(mdb), &sts); err != nil { - log.FromContext(ctx).V(1).Info("error getting StatefulSet", "err", err) + logger.Info("error getting StatefulSet", "err", err) } replicationStatus, replErr := r.getReplicationStatus(ctx, mdb) if replErr != nil { - log.FromContext(ctx).V(1).Info("error getting replication status", "err", replErr) + logger.Info("error getting replication status", "err", replErr) } + mxsPrimaryPodIndex, mxsErr := r.getMaxScalePrimaryPod(ctx, mdb) if mxsErr != nil { - log.FromContext(ctx).V(1).Info("error getting MaxScale primary Pod", "err", mxsErr) + logger.Info("error getting MaxScale primary Pod", "err", mxsErr) + } + + tlsStatus, err := r.getTLSStatus(ctx, mdb) + if err != nil { + logger.Info("error getting TLS status", "err", err) } return ctrl.Result{}, r.patchStatus(ctx, mdb, func(status *mariadbv1alpha1.MariaDBStatus) error { + status.DefaultVersion = r.Environment.MariadbDefaultVersion status.Replicas = sts.Status.ReadyReplicas defaultPrimary(mdb) setMaxScalePrimary(mdb, mxsPrimaryPodIndex) @@ -54,6 +62,10 @@ func (r *MariaDBReconciler) reconcileStatus(ctx context.Context, mdb *mariadbv1a status.ReplicationStatus = replicationStatus } + if tlsStatus != nil { + status.TLS = tlsStatus + } + if apierrors.IsNotFound(mxsErr) && !ptr.Deref(mdb.Spec.MaxScale, mariadbv1alpha1.MariaDBMaxScaleSpec{}).Enabled { r.ConditionReady.PatcherRefResolver(mxsErr, mariadbv1alpha1.MaxScale{})(&mdb.Status) return nil diff --git a/internal/controller/mariadb_controller_test.go b/internal/controller/mariadb_controller_test.go index d7c4b3a780..0ba13d7e56 100644 --- a/internal/controller/mariadb_controller_test.go +++ b/internal/controller/mariadb_controller_test.go @@ -41,7 +41,7 @@ var _ = Describe("MariaDB spec", func() { } Expect(k8sClient.Create(testCtx, &mdb)).To(Succeed()) DeferCleanup(func() { - deleteMariadb(key) + deleteMariadb(key, false) }) By("Expecting to eventually default") @@ -106,7 +106,7 @@ var _ = Describe("MariaDB", func() { } Expect(k8sClient.Create(testCtx, &mdb)).To(Succeed()) DeferCleanup(func() { - deleteMariadb(key) + deleteMariadb(key, false) }) By("Suspend MariaDB") @@ -425,7 +425,7 @@ var _ = Describe("MariaDB", func() { By("Creating MariaDB") Expect(k8sClient.Create(testCtx, &mdb)).To(Succeed()) DeferCleanup(func() { - deleteMariadb(key) + deleteMariadb(key, false) }) By("Expecting MariaDB to be ready eventually") @@ -471,7 +471,7 @@ var _ = Describe("MariaDB", func() { By("Creating MariaDB") Expect(k8sClient.Create(testCtx, &mdb)).To(Succeed()) DeferCleanup(func() { - deleteMariadb(key) + deleteMariadb(key, false) }) By("Expecting MariaDB to be ready eventually") @@ -556,7 +556,7 @@ var _ = Describe("MariaDB", func() { By("Creating MariaDB") Expect(k8sClient.Create(testCtx, &mdb)).To(Succeed()) DeferCleanup(func() { - deleteMariadb(key) + deleteMariadb(key, false) }) By("Expecting MariaDB to be ready eventually") @@ -737,7 +737,7 @@ func testMariadbBootstrap(key types.NamespacedName, source mariadbv1alpha1.Resto By("Creating MariaDB") Expect(k8sClient.Create(testCtx, &mdb)).To(Succeed()) DeferCleanup(func() { - deleteMariadb(key) + deleteMariadb(key, false) }) By("Expecting MariaDB to be ready eventually") diff --git a/internal/controller/mariadb_controller_tls.go b/internal/controller/mariadb_controller_tls.go new file mode 100644 index 0000000000..c183447790 --- /dev/null +++ b/internal/controller/mariadb_controller_tls.go @@ -0,0 +1,454 @@ +package controller + +import ( + "bytes" + "context" + "errors" + "fmt" + + mariadbv1alpha1 "github.com/mariadb-operator/mariadb-operator/api/v1alpha1" + labels "github.com/mariadb-operator/mariadb-operator/pkg/builder/labels" + builderpki "github.com/mariadb-operator/mariadb-operator/pkg/builder/pki" + condition "github.com/mariadb-operator/mariadb-operator/pkg/condition" + certctrl "github.com/mariadb-operator/mariadb-operator/pkg/controller/certificate" + "github.com/mariadb-operator/mariadb-operator/pkg/controller/configmap" + "github.com/mariadb-operator/mariadb-operator/pkg/controller/secret" + "github.com/mariadb-operator/mariadb-operator/pkg/metadata" + "github.com/mariadb-operator/mariadb-operator/pkg/pki" + "github.com/mariadb-operator/mariadb-operator/pkg/pod" + "github.com/mariadb-operator/mariadb-operator/pkg/refresolver" + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + klabels "k8s.io/apimachinery/pkg/labels" + "k8s.io/apimachinery/pkg/types" + "k8s.io/utils/ptr" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/log" +) + +func (r *MariaDBReconciler) reconcileTLS(ctx context.Context, mariadb *mariadbv1alpha1.MariaDB) (ctrl.Result, error) { + if !mariadb.IsTLSEnabled() { + return ctrl.Result{}, nil + } + if result, err := r.reconcileTLSCerts(ctx, mariadb); !result.IsZero() || err != nil { + return result, err + } + if err := r.reconcileTLSCABundle(ctx, mariadb); err != nil { + return ctrl.Result{}, err + } + if err := r.reconcileTLSConfig(ctx, mariadb); err != nil { + return ctrl.Result{}, err + } + return ctrl.Result{}, nil +} + +func (r *MariaDBReconciler) reconcileTLSCerts(ctx context.Context, mdb *mariadbv1alpha1.MariaDB) (ctrl.Result, error) { + tls := ptr.Deref(mdb.Spec.TLS, mariadbv1alpha1.TLS{}) + certHandler := newCertHandler(r.Client, r.RefResolver, mdb) + + serverCertOpts := []certctrl.CertReconcilerOpt{ + certctrl.WithCABundle(mdb.TLSCABundleSecretKeyRef(), mdb.Namespace), + certctrl.WithCA( + tls.ServerCASecretRef == nil, + mdb.TLSServerCASecretKey(), + ), + certctrl.WithCert( + tls.ServerCertSecretRef == nil, + mdb.TLSServerCertSecretKey(), + mdb.TLSServerDNSNames(), + ), + certctrl.WithCertHandler(certHandler), + certctrl.WithCertIssuerRef(tls.ServerCertIssuerRef), + certctrl.WithRelatedObject(mdb), + } + serverCertOpts = append(serverCertOpts, tlsServerCertOpts(mdb)...) + + if result, err := r.CertReconciler.Reconcile(ctx, serverCertOpts...); !result.IsZero() || err != nil { + if err != nil { + return ctrl.Result{}, fmt.Errorf("error reconciling server cert: %w", err) + } + return result.Result, nil + } + + clientCertOpts := []certctrl.CertReconcilerOpt{ + certctrl.WithCABundle(mdb.TLSCABundleSecretKeyRef(), mdb.Namespace), + certctrl.WithCA( + tls.ClientCASecretRef == nil, + mdb.TLSClientCASecretKey(), + ), + certctrl.WithCert( + tls.ClientCertSecretRef == nil, + mdb.TLSClientCertSecretKey(), + mdb.TLSClientNames(), + ), + certctrl.WithCertHandler(certHandler), + certctrl.WithCertIssuerRef(tls.ClientCertIssuerRef), + certctrl.WithRelatedObject(mdb), + } + clientCertOpts = append(clientCertOpts, tlsClientCertOpts(mdb)...) + + if result, err := r.CertReconciler.Reconcile(ctx, clientCertOpts...); !result.IsZero() || err != nil { + if err != nil { + return ctrl.Result{}, fmt.Errorf("error reconciling client cert: %w", err) + } + return result.Result, nil + } + + return ctrl.Result{}, nil +} + +func (r *MariaDBReconciler) reconcileTLSCABundle(ctx context.Context, mdb *mariadbv1alpha1.MariaDB) error { + logger := log.FromContext(ctx).WithName("ca-bundle") + + caBundleKeySelector := mdb.TLSCABundleSecretKeyRef() + serverCAKeySelector := mariadbv1alpha1.SecretKeySelector{ + LocalObjectReference: mariadbv1alpha1.LocalObjectReference{ + Name: mdb.TLSServerCASecretKey().Name, + }, + Key: pki.CACertKey, + } + clientCAKeySelector := mariadbv1alpha1.SecretKeySelector{ + LocalObjectReference: mariadbv1alpha1.LocalObjectReference{ + Name: mdb.TLSClientCASecretKey().Name, + }, + Key: pki.CACertKey, + } + caKeySelectors := []mariadbv1alpha1.SecretKeySelector{ + caBundleKeySelector, + serverCAKeySelector, + clientCAKeySelector, + } + var caBundles [][]byte + + for _, caKeySelector := range caKeySelectors { + ca, err := r.RefResolver.SecretKeyRef(ctx, caKeySelector, mdb.Namespace) + if err != nil { + if !apierrors.IsNotFound(err) { + return fmt.Errorf("error getting CA Secret \"%s\": %v", caKeySelector.Name, err) + } + logger.V(1).Info("CA Secret not found", "secret-name", caKeySelector.Name) + } + caBundles = append(caBundles, []byte(ca)) + } + + bundle, err := pki.BundleCertificatePEMs( + caBundles, + pki.WithLogger(logger), + pki.WithSkipExpired(true), + ) + if err != nil { + return fmt.Errorf("error creating CA bundle: %v", err) + } + + secretReq := secret.SecretRequest{ + Metadata: []*mariadbv1alpha1.Metadata{mdb.Spec.InheritMetadata}, + Owner: mdb, + Key: types.NamespacedName{ + Name: caBundleKeySelector.Name, + Namespace: mdb.Namespace, + }, + Data: map[string][]byte{ + caBundleKeySelector.Key: bundle, + }, + } + return r.SecretReconciler.Reconcile(ctx, &secretReq) +} + +func (r *MariaDBReconciler) reconcileTLSConfig(ctx context.Context, mariadb *mariadbv1alpha1.MariaDB) error { + configMapKeyRef := mariadb.TLSConfigMapKeyRef() + + tpl := createTpl("tls", `[mariadb] +ssl_cert = {{ .SSLCert }} +ssl_key = {{ .SSLKey }} +ssl_ca = {{ .SSLCA }} +require_secure_transport = true +tls_version = TLSv1.3 +`) + buf := new(bytes.Buffer) + err := tpl.Execute(buf, struct { + SSLCert string + SSLKey string + SSLCA string + }{ + SSLCert: builderpki.ServerCertPath, + SSLKey: builderpki.ServerKeyPath, + SSLCA: builderpki.CACertPath, + }) + if err != nil { + return fmt.Errorf("error rendering TLS config: %v", err) + } + + configMapReq := configmap.ReconcileRequest{ + Metadata: mariadb.Spec.InheritMetadata, + Owner: mariadb, + Key: types.NamespacedName{ + Name: configMapKeyRef.Name, + Namespace: mariadb.Namespace, + }, + Data: map[string]string{ + configMapKeyRef.Key: buf.String(), + }, + } + return r.ConfigMapReconciler.Reconcile(ctx, &configMapReq) +} + +func (r *MariaDBReconciler) getTLSAnnotations(ctx context.Context, mariadb *mariadbv1alpha1.MariaDB) (map[string]string, error) { + if !mariadb.IsTLSEnabled() { + return nil, nil + } + + annotations, err := r.getTLSClientAnnotations(ctx, mariadb) + if err != nil { + return nil, fmt.Errorf("error getting client annotations: %v", err) + } + + serverCertKeySelector := mariadbv1alpha1.SecretKeySelector{ + LocalObjectReference: mariadbv1alpha1.LocalObjectReference{ + Name: mariadb.TLSServerCertSecretKey().Name, + }, + Key: pki.TLSCertKey, + } + serverCert, err := r.RefResolver.SecretKeyRef(ctx, serverCertKeySelector, mariadb.Namespace) + if err != nil { + return nil, fmt.Errorf("error getting server cert: %v", err) + } + annotations[metadata.TLSServerCertAnnotation] = hash(serverCert) + + return annotations, nil +} + +func (r *MariaDBReconciler) getTLSClientAnnotations(ctx context.Context, mariadb *mariadbv1alpha1.MariaDB) (map[string]string, error) { + if !mariadb.IsTLSEnabled() { + return nil, nil + } + annotations := make(map[string]string) + + ca, err := r.RefResolver.SecretKeyRef(ctx, mariadb.TLSCABundleSecretKeyRef(), mariadb.Namespace) + if err != nil { + return nil, fmt.Errorf("error getting CA bundle: %v", err) + } + annotations[metadata.TLSCAAnnotation] = hash(ca) + + clientCertKeySelector := mariadbv1alpha1.SecretKeySelector{ + LocalObjectReference: mariadbv1alpha1.LocalObjectReference{ + Name: mariadb.TLSClientCertSecretKey().Name, + }, + Key: pki.TLSCertKey, + } + clientCert, err := r.RefResolver.SecretKeyRef(ctx, clientCertKeySelector, mariadb.Namespace) + if err != nil { + return nil, fmt.Errorf("error getting client cert: %v", err) + } + annotations[metadata.TLSClientCertAnnotation] = hash(clientCert) + + return annotations, nil +} + +func (r *MariaDBReconciler) getTLSStatus(ctx context.Context, mdb *mariadbv1alpha1.MariaDB) (*mariadbv1alpha1.MariaDBTLSStatus, error) { + if !mdb.IsTLSEnabled() { + return nil, nil + } + var tlsStatus mariadbv1alpha1.MariaDBTLSStatus + + certStatus, err := getCertificateStatus(ctx, r.RefResolver, mdb.TLSCABundleSecretKeyRef(), mdb.Namespace) + if err != nil { + return nil, fmt.Errorf("error getting CA bundle status: %v", err) + } + tlsStatus.CABundle = certStatus + + secretKeySelector := mariadbv1alpha1.SecretKeySelector{ + LocalObjectReference: mariadbv1alpha1.LocalObjectReference{ + Name: mdb.TLSServerCertSecretKey().Name, + }, + Key: pki.TLSCertKey, + } + certStatus, err = getCertificateStatus(ctx, r.RefResolver, secretKeySelector, mdb.Namespace) + if err != nil { + return nil, fmt.Errorf("error getting Server certificate status: %v", err) + } + tlsStatus.ServerCert = ptr.To(certStatus[0]) + + secretKeySelector = mariadbv1alpha1.SecretKeySelector{ + LocalObjectReference: mariadbv1alpha1.LocalObjectReference{ + Name: mdb.TLSClientCertSecretKey().Name, + }, + Key: pki.TLSCertKey, + } + certStatus, err = getCertificateStatus(ctx, r.RefResolver, secretKeySelector, mdb.Namespace) + if err != nil { + return nil, fmt.Errorf("error getting Client certificate status: %v", err) + } + tlsStatus.ClientCert = ptr.To(certStatus[0]) + + return &tlsStatus, nil +} + +type certHandler struct { + client.Client + refResolver *refresolver.RefResolver + mdb *mariadbv1alpha1.MariaDB +} + +func newCertHandler(client client.Client, refResolver *refresolver.RefResolver, mdb *mariadbv1alpha1.MariaDB) *certHandler { + return &certHandler{ + Client: client, + refResolver: refResolver, + mdb: mdb, + } +} + +func (h *certHandler) ShouldRenewCert(ctx context.Context, caKeyPair *pki.KeyPair) (shouldRenew bool, reason string, err error) { + if !h.mdb.IsReady() { + return false, "MariaDB not ready", fmt.Errorf("MariaDB not ready: %w", certctrl.ErrSkipCertRenewal) + } + + caLeafCert, err := caKeyPair.LeafCertificate() + if err != nil { + return false, "", fmt.Errorf("error getting CA leaf certificate: %v", err) + } + + caBundleBytes, err := h.refResolver.SecretKeyRef(ctx, h.mdb.TLSCABundleSecretKeyRef(), h.mdb.Namespace) + if err != nil { + return false, "", fmt.Errorf("error getting CA bundle: %w", err) + } + caCerts, err := pki.ParseCertificates([]byte(caBundleBytes)) + if err != nil { + return false, "", fmt.Errorf("error parsing CA certs: %v", err) + } + + serialNo := caLeafCert.SerialNumber + hasSerialNo := false + for _, cert := range caCerts { + if cert.SerialNumber.Cmp(serialNo) == 0 { + hasSerialNo = true + break + } + } + // CA bundle hasn't been updated with the CA + if !hasSerialNo { + return false, fmt.Sprintf("Missing CA with serial number '%s' in CA bundle", serialNo.String()), nil + } + + allPodsTrustingCA, err := h.ensureAllPodsTrustingCABundle(ctx, h.mdb, hash(caBundleBytes)) + if err != nil { + return false, "", fmt.Errorf("error checking pod CAs: %v", err) + } + // Some Pods are still not trusting the CA, a rolling upgrade is pending/ongoing + if !allPodsTrustingCA { + return false, "Waiting for all Pods to trust CA", nil + } + + return true, "", nil +} + +func (h *certHandler) HandleExpiredCert(ctx context.Context) error { + if h.mdb.IsReady() { + return nil + } + if h.mdb.IsGaleraEnabled() { + if err := h.patchStatus(ctx, h.mdb, func(status *mariadbv1alpha1.MariaDBStatus) { + status.GaleraRecovery = nil + condition.SetGaleraNotReady(status) + }); err != nil { + return fmt.Errorf("error patching MariaDB status: %v", err) + } + } + return nil +} + +func (h *certHandler) ensureAllPodsTrustingCABundle(ctx context.Context, mdb *mariadbv1alpha1.MariaDB, + caBundleHash string) (bool, error) { + logger := log.FromContext(ctx).WithName("pod-ca").WithValues("ca-hash", caBundleHash) + + list := corev1.PodList{} + listOpts := &client.ListOptions{ + LabelSelector: klabels.SelectorFromSet( + labels.NewLabelsBuilder(). + WithMariaDBSelectorLabels(mdb). + Build(), + ), + Namespace: mdb.GetNamespace(), + } + if err := h.List(ctx, &list, listOpts); err != nil { + return false, fmt.Errorf("error listing Pods: %v", err) + } + if len(list.Items) != int(mdb.Spec.Replicas) { + return false, errors.New("some Pods are missing") + } + + for _, p := range list.Items { + if !pod.PodReady(&p) { + logger.V(1).Info("Pod not ready", "pod", p.Name) + return false, nil + } + + annotations := p.ObjectMeta.Annotations + if annotations == nil { + return false, nil + } + caAnnotation, ok := annotations[metadata.TLSCAAnnotation] + if !ok { + logger.V(1).Info("CA annotation not present", "pod", p.Name) + return false, nil + } + if caAnnotation != caBundleHash { + logger.V(1).Info("CA annotation mistmatch", "pod", p.Name, "pod-hash", caAnnotation) + return false, nil + } + } + return true, nil +} + +func (r *certHandler) patchStatus(ctx context.Context, mariadb *mariadbv1alpha1.MariaDB, + patcher func(*mariadbv1alpha1.MariaDBStatus)) error { + patch := client.MergeFrom(mariadb.DeepCopy()) + patcher(&mariadb.Status) + return r.Status().Patch(ctx, mariadb, patch) +} + +func getCertificateStatus(ctx context.Context, refResolver *refresolver.RefResolver, selector mariadbv1alpha1.SecretKeySelector, + namespace string) ([]mariadbv1alpha1.CertificateStatus, error) { + secret, err := refResolver.SecretKeyRef(ctx, selector, namespace) + if err != nil { + return nil, fmt.Errorf("error getting Secret: %v", err) + } + + certs, err := pki.ParseCertificates([]byte(secret)) + if err != nil { + return nil, fmt.Errorf("error getting certificates: %v", err) + } + if len(certs) == 0 { + return nil, errors.New("no certificates were found") + } + + status := make([]mariadbv1alpha1.CertificateStatus, len(certs)) + for i, cert := range certs { + status[i] = mariadbv1alpha1.CertificateStatus{ + NotAfter: metav1.NewTime(cert.NotAfter), + NotBefore: metav1.NewTime(cert.NotBefore), + Subject: cert.Subject.String(), + Issuer: cert.Issuer.String(), + } + } + return status, nil +} + +func tlsServerCertOpts(mdb *mariadbv1alpha1.MariaDB) []certctrl.CertReconcilerOpt { + var opts []certctrl.CertReconcilerOpt + // Galera not compatible with key usages + if !mdb.IsGaleraEnabled() { + opts = append(opts, certctrl.WithServerCertKeyUsage()) + } + return opts +} + +func tlsClientCertOpts(mdb *mariadbv1alpha1.MariaDB) []certctrl.CertReconcilerOpt { + var opts []certctrl.CertReconcilerOpt + // Galera not compatible with key usages + if !mdb.IsGaleraEnabled() { + opts = append(opts, certctrl.WithClientCertKeyUsage()) + } + return opts +} diff --git a/internal/controller/mariadb_controller_update.go b/internal/controller/mariadb_controller_update.go index 2b35efaa42..0fa19afbb7 100644 --- a/internal/controller/mariadb_controller_update.go +++ b/internal/controller/mariadb_controller_update.go @@ -2,15 +2,21 @@ package controller import ( "context" + "crypto/sha256" "errors" "fmt" "sort" + "strconv" "time" "github.com/go-logr/logr" mariadbv1alpha1 "github.com/mariadb-operator/mariadb-operator/api/v1alpha1" labels "github.com/mariadb-operator/mariadb-operator/pkg/builder/labels" + builderpki "github.com/mariadb-operator/mariadb-operator/pkg/builder/pki" + "github.com/mariadb-operator/mariadb-operator/pkg/environment" + galeraconfig "github.com/mariadb-operator/mariadb-operator/pkg/galera/config" "github.com/mariadb-operator/mariadb-operator/pkg/health" + "github.com/mariadb-operator/mariadb-operator/pkg/metadata" podpkg "github.com/mariadb-operator/mariadb-operator/pkg/pod" "github.com/mariadb-operator/mariadb-operator/pkg/wait" appsv1 "k8s.io/api/apps/v1" @@ -94,6 +100,75 @@ func (r *MariaDBReconciler) reconcileUpdates(ctx context.Context, mdb *mariadbv1 return ctrl.Result{}, nil } +func (r *MariaDBReconciler) getUpdateAnnotations(ctx context.Context, mariadb *mariadbv1alpha1.MariaDB) (map[string]string, error) { + podAnnotations := make(map[string]string) + + if mariadb.Spec.MyCnfConfigMapKeyRef != nil { + config, err := r.RefResolver.ConfigMapKeyRef(ctx, mariadb.Spec.MyCnfConfigMapKeyRef, mariadb.Namespace) + if err != nil { + return nil, fmt.Errorf("error getting my.cnf from ConfigMap: %v", err) + } + podAnnotations[metadata.ConfigAnnotation] = hash(config) + } + + if mariadb.IsGaleraEnabled() { + logger := log.FromContext(ctx).WithName("galera-config") + env := &environment.PodEnvironment{ + ClusterName: "cluster.local", + PodIP: "10.0.0.0", + PodName: "pod-name", + MariadbName: mariadb.Name, + MariadbRootPassword: "password", + MariadbPort: strconv.Itoa(int(mariadb.Spec.Port)), + TLSEnabled: strconv.FormatBool(mariadb.IsTLSEnabled()), + TLSCACertPath: builderpki.CACertPath, + TLSServerCertPath: builderpki.ServerCertPath, + TLSServerKeyPath: builderpki.ServerKeyPath, + TLSClientCertPath: builderpki.ClientCertPath, + TLSClientKeyPath: builderpki.ClientKeyPath, + } + config, err := galeraconfig.NewConfigFile(mariadb, r.Discovery, logger).Marshal(env) + if err != nil { + return nil, fmt.Errorf("error rendering Galera config file: %v", err) + } + podAnnotations[metadata.ConfigGaleraAnnotation] = hash(string(config)) + } + + if mariadb.IsTLSEnabled() { + tlsAnnotations, err := r.getTLSAnnotations(ctx, mariadb) + if err != nil { + return nil, fmt.Errorf("error getting TLS annotations: %v", err) + } + for k, v := range tlsAnnotations { + podAnnotations[k] = v + } + } + + return podAnnotations, nil +} + +func (r *MariaDBReconciler) getExporterUpdateAnnotations(ctx context.Context, mdb *mariadbv1alpha1.MariaDB) (map[string]string, error) { + config, err := r.RefResolver.SecretKeyRef(ctx, mdb.MetricsConfigSecretKeyRef().SecretKeySelector, mdb.Namespace) + if err != nil { + return nil, fmt.Errorf("error getting metrics config Secret: %v", err) + } + podAnnotations := map[string]string{ + metadata.ConfigAnnotation: hash(config), + } + + if mdb.IsTLSEnabled() { + tlsAnnotations, err := r.getTLSClientAnnotations(ctx, mdb) + if err != nil { + return nil, fmt.Errorf("error getting TLS client annotations: %v", err) + } + for k, v := range tlsAnnotations { + podAnnotations[k] = v + } + } + + return podAnnotations, nil +} + func (r *MariaDBReconciler) waitForReadyStatus(ctx context.Context, mdb *mariadbv1alpha1.MariaDB, logger logr.Logger) (ctrl.Result, error) { var sts appsv1.StatefulSet if err := r.Get(ctx, client.ObjectKeyFromObject(mdb), &sts); err != nil { @@ -276,3 +351,7 @@ func shouldTriggerSwitchover(mariadb *mariadbv1alpha1.MariaDB) bool { primaryRepl := ptr.Deref(mariadb.Replication().Primary, mariadbv1alpha1.PrimaryReplication{}) return mariadb.Replication().Enabled && *primaryRepl.AutomaticFailover && mariadb.IsReplicationConfigured() } + +func hash(config string) string { + return fmt.Sprintf("%x", sha256.Sum256([]byte(config))) +} diff --git a/internal/controller/maxscale_controller.go b/internal/controller/maxscale_controller.go index 0bada01f54..809300dd0a 100644 --- a/internal/controller/maxscale_controller.go +++ b/internal/controller/maxscale_controller.go @@ -13,6 +13,7 @@ import ( labels "github.com/mariadb-operator/mariadb-operator/pkg/builder/labels" condition "github.com/mariadb-operator/mariadb-operator/pkg/condition" "github.com/mariadb-operator/mariadb-operator/pkg/controller/auth" + certctrl "github.com/mariadb-operator/mariadb-operator/pkg/controller/certificate" "github.com/mariadb-operator/mariadb-operator/pkg/controller/deployment" "github.com/mariadb-operator/mariadb-operator/pkg/controller/rbac" "github.com/mariadb-operator/mariadb-operator/pkg/controller/secret" @@ -24,12 +25,9 @@ import ( "github.com/mariadb-operator/mariadb-operator/pkg/environment" mxsclient "github.com/mariadb-operator/mariadb-operator/pkg/maxscale/client" mxsconfig "github.com/mariadb-operator/mariadb-operator/pkg/maxscale/config" - "github.com/mariadb-operator/mariadb-operator/pkg/metadata" "github.com/mariadb-operator/mariadb-operator/pkg/pod" - "github.com/mariadb-operator/mariadb-operator/pkg/predicate" "github.com/mariadb-operator/mariadb-operator/pkg/refresolver" stsobj "github.com/mariadb-operator/mariadb-operator/pkg/statefulset" - "github.com/mariadb-operator/mariadb-operator/pkg/watch" appsv1 "k8s.io/api/apps/v1" corev1 "k8s.io/api/core/v1" policyv1 "k8s.io/api/policy/v1" @@ -41,7 +39,6 @@ import ( "k8s.io/client-go/tools/record" "k8s.io/utils/ptr" ctrl "sigs.k8s.io/controller-runtime" - ctrlbuilder "sigs.k8s.io/controller-runtime/pkg/builder" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/controller" "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" @@ -69,6 +66,7 @@ type MaxScaleReconciler struct { ServiceReconciler *service.ServiceReconciler DeploymentReconciler *deployment.DeploymentReconciler ServiceMonitorReconciler *servicemonitor.ServiceMonitorReconciler + CertReconciler *certctrl.CertReconciler SuspendEnabled bool @@ -101,6 +99,7 @@ type reconcilePhaseMaxScale struct { //+kubebuilder:rbac:groups=policy,resources=poddisruptionbudgets,verbs=list;watch;create;patch //+kubebuilder:rbac:groups=apps,resources=deployments,verbs=list;watch;create;patch //+kubebuilder:rbac:groups=monitoring.coreos.com,resources=servicemonitors,verbs=list;watch;create;patch +//+kubebuilder:rbac:groups=cert-manager.io,resources=certificates,verbs=list;watch;create;patch // Reconcile is part of the main kubernetes reconciliation loop which aims to // move the current state of the cluster closer to the desired state. @@ -139,6 +138,10 @@ func (r *MaxScaleReconciler) Reconcile(ctx context.Context, req ctrl.Request) (c name: "Secret", reconcile: r.reconcileSecret, }, + { + name: "TLS", + reconcile: r.reconcileTLS, + }, { name: "Auth", reconcile: r.reconcileAuth, @@ -464,8 +467,18 @@ func (r *MaxScaleReconciler) reconcileServiceAccount(ctx context.Context, req *r } func (r *MaxScaleReconciler) reconcileStatefulSet(ctx context.Context, req *requestMaxScale) (ctrl.Result, error) { + var podAnnotations map[string]string + var err error + if req.mxs.IsTLSEnabled() { + var err error + podAnnotations, err = r.getTLSAnnotations(ctx, req.mxs) + if err != nil { + return ctrl.Result{}, fmt.Errorf("error getting TLS annotations: %v", err) + } + } + key := client.ObjectKeyFromObject(req.mxs) - desiredSts, err := r.Builder.BuildMaxscaleStatefulSet(req.mxs, key) + desiredSts, err := r.Builder.BuildMaxscaleStatefulSet(req.mxs, key, podAnnotations) if err != nil { return ctrl.Result{}, fmt.Errorf("error building StatefulSet: %v", err) } @@ -1417,18 +1430,8 @@ func (r *MaxScaleReconciler) SetupWithManager(ctx context.Context, mgr ctrl.Mana Owns(&appsv1.Deployment{}). WithOptions(opts) - watcherIndexer := watch.NewWatcherIndexer(mgr, builder, r.Client) - if err := watcherIndexer.Watch( - ctx, - &corev1.Secret{}, - &mariadbv1alpha1.MaxScale{}, - &mariadbv1alpha1.MaxScaleList{}, - mariadbv1alpha1.MaxScaleMetricsPasswordSecretFieldPath, - ctrlbuilder.WithPredicates( - predicate.PredicateWithLabel(metadata.WatchLabel), - ), - ); err != nil { - return fmt.Errorf("error watching: %v", err) + if err := mariadbv1alpha1.IndexMaxScale(ctx, mgr, builder, r.Client); err != nil { + return fmt.Errorf("error indexing MaxScale: %v", err) } return builder.Complete(r) diff --git a/internal/controller/maxscale_controller_api.go b/internal/controller/maxscale_controller_api.go index 354cf22eda..db520aa5a1 100644 --- a/internal/controller/maxscale_controller_api.go +++ b/internal/controller/maxscale_controller_api.go @@ -1,6 +1,7 @@ package controller import ( + "bytes" "context" "errors" "fmt" @@ -9,10 +10,12 @@ import ( "github.com/go-logr/logr" mariadbv1alpha1 "github.com/mariadb-operator/mariadb-operator/api/v1alpha1" + builderpki "github.com/mariadb-operator/mariadb-operator/pkg/builder/pki" ds "github.com/mariadb-operator/mariadb-operator/pkg/datastructures" "github.com/mariadb-operator/mariadb-operator/pkg/health" mdbhttp "github.com/mariadb-operator/mariadb-operator/pkg/http" mxsclient "github.com/mariadb-operator/mariadb-operator/pkg/maxscale/client" + "github.com/mariadb-operator/mariadb-operator/pkg/pki" "github.com/mariadb-operator/mariadb-operator/pkg/refresolver" "k8s.io/utils/ptr" "sigs.k8s.io/controller-runtime/pkg/log" @@ -54,7 +57,11 @@ func (m *maxScaleAPI) patchUser(ctx context.Context, username, password string) // MaxScale API - Servers func (m *maxScaleAPI) createServer(ctx context.Context, srv *mariadbv1alpha1.MaxScaleServer) error { - return m.client.Server.Create(ctx, srv.Name, serverAttributes(srv)) + serverAttrs, err := m.serverAttributes(srv) + if err != nil { + return fmt.Errorf("error getting server attributes: %v", err) + } + return m.client.Server.Create(ctx, srv.Name, *serverAttrs) } func (m *maxScaleAPI) deleteServer(ctx context.Context, name string) error { @@ -62,7 +69,11 @@ func (m *maxScaleAPI) deleteServer(ctx context.Context, name string) error { } func (m *maxScaleAPI) patchServer(ctx context.Context, srv *mariadbv1alpha1.MaxScaleServer) error { - return m.client.Server.Patch(ctx, srv.Name, serverAttributes(srv)) + serverAttrs, err := m.serverAttributes(srv) + if err != nil { + return fmt.Errorf("error getting server attributes: %v", err) + } + return m.client.Server.Patch(ctx, srv.Name, *serverAttrs) } func (m *maxScaleAPI) updateServerState(ctx context.Context, srv *mariadbv1alpha1.MaxScaleServer) error { @@ -72,8 +83,8 @@ func (m *maxScaleAPI) updateServerState(ctx context.Context, srv *mariadbv1alpha return m.client.Server.ClearMaintenance(ctx, srv.Name) } -func serverAttributes(srv *mariadbv1alpha1.MaxScaleServer) mxsclient.ServerAttributes { - return mxsclient.ServerAttributes{ +func (m *maxScaleAPI) serverAttributes(srv *mariadbv1alpha1.MaxScaleServer) (*mxsclient.ServerAttributes, error) { + attrs := mxsclient.ServerAttributes{ Parameters: mxsclient.ServerParameters{ Address: srv.Address, Port: srv.Port, @@ -81,6 +92,51 @@ func serverAttributes(srv *mariadbv1alpha1.MaxScaleServer) mxsclient.ServerAttri Params: mxsclient.NewMapParams(srv.Params), }, } + tls := ptr.Deref(m.mxs.Spec.TLS, mariadbv1alpha1.MaxScaleTLS{}) + if tls.Enabled { + attrs.Parameters.SSL = true + attrs.Parameters.SSLCert = builderpki.ServerCertPath + attrs.Parameters.SSLKey = builderpki.ServerKeyPath + attrs.Parameters.SSLCA = builderpki.CACertPath + attrs.Parameters.SSLVersion = "TLSv13" + attrs.Parameters.SSLVerifyPeerCertificate = ptr.Deref(tls.VerifyPeerCertificate, true) + attrs.Parameters.SSLVerifyPeerHost = ptr.Deref(tls.VerifyPeerHost, false) + + if ptr.Deref(tls.ReplicationSSLEnabled, false) { + replicationCustomOptions, err := maxScaleReplicationCustomOptions(&tls) + if err != nil { + return nil, err + } + attrs.Parameters.ReplicationCustomOptions = replicationCustomOptions + } + } + return &attrs, nil +} + +func maxScaleReplicationCustomOptions(tls *mariadbv1alpha1.MaxScaleTLS) (string, error) { + if !tls.Enabled { + return "", errors.New("MaxScale TLS must be enabled") + } + if !ptr.Deref(tls.ReplicationSSLEnabled, false) { + return "", nil + } + + //nolint:lll + tpl := createTpl("replication-custom-opts", `MASTER_SSL=1,MASTER_SSL_CERT={{ .SSLCert }},MASTER_SSL_KEY={{ .SSLKey }},MASTER_SSL_CA={{ .SSLCA }}`) + buf := new(bytes.Buffer) + err := tpl.Execute(buf, struct { + SSLCert string + SSLKey string + SSLCA string + }{ + SSLCert: builderpki.ServerCertPath, + SSLKey: builderpki.ServerKeyPath, + SSLCA: builderpki.CACertPath, + }) + if err != nil { + return "", fmt.Errorf("error building replication custom options: %v", err) + } + return buf.String(), nil } func (m *maxScaleAPI) serverRelationships(ctx context.Context) (*mxsclient.Relationships, error) { @@ -199,7 +255,7 @@ func (m *maxScaleAPI) serviceRelationships(service string) *mxsclient.Relationsh // MaxScale API - Listeners func (m *maxScaleAPI) createListener(ctx context.Context, listener *mariadbv1alpha1.MaxScaleListener, rels *mxsclient.Relationships) error { - return m.client.Listener.Create(ctx, listener.Name, listenerAttributes(listener), mxsclient.WithRelationships(rels)) + return m.client.Listener.Create(ctx, listener.Name, m.listenerAttributes(listener), mxsclient.WithRelationships(rels)) } func (m *maxScaleAPI) deleteListener(ctx context.Context, name string) error { @@ -207,7 +263,7 @@ func (m *maxScaleAPI) deleteListener(ctx context.Context, name string) error { } func (m *maxScaleAPI) patchListener(ctx context.Context, listener *mariadbv1alpha1.MaxScaleListener, rels *mxsclient.Relationships) error { - return m.client.Listener.Patch(ctx, listener.Name, listenerAttributes(listener), mxsclient.WithRelationships(rels)) + return m.client.Listener.Patch(ctx, listener.Name, m.listenerAttributes(listener), mxsclient.WithRelationships(rels)) } func (m *maxScaleAPI) updateListenerState(ctx context.Context, listener *mariadbv1alpha1.MaxScaleListener) error { @@ -217,14 +273,24 @@ func (m *maxScaleAPI) updateListenerState(ctx context.Context, listener *mariadb return m.client.Listener.Start(ctx, listener.Name) } -func listenerAttributes(listener *mariadbv1alpha1.MaxScaleListener) mxsclient.ListenerAttributes { - return mxsclient.ListenerAttributes{ +func (m *maxScaleAPI) listenerAttributes(listener *mariadbv1alpha1.MaxScaleListener) mxsclient.ListenerAttributes { + attrs := mxsclient.ListenerAttributes{ Parameters: mxsclient.ListenerParameters{ Port: listener.Port, Protocol: listener.Protocol, Params: mxsclient.NewMapParams(listener.Params), }, } + tls := ptr.Deref(m.mxs.Spec.TLS, mariadbv1alpha1.MaxScaleTLS{}) + if tls.Enabled { + attrs.Parameters.SSL = true + attrs.Parameters.SSLCert = builderpki.ListenerCertPath + attrs.Parameters.SSLKey = builderpki.ListenerKeyPath + attrs.Parameters.SSLCA = builderpki.CACertPath + attrs.Parameters.SSLVerifyPeerCertificate = ptr.Deref(tls.VerifyPeerCertificate, true) + attrs.Parameters.SSLVerifyPeerHost = ptr.Deref(tls.VerifyPeerHost, false) + } + return attrs } // MaxScale API - MaxScale @@ -277,6 +343,13 @@ func (r *MaxScaleReconciler) defaultClientWithPodIndex(ctx context.Context, mxs logger := apiLogger(ctx) opts = append(opts, mdbhttp.WithLogger(&logger)) } + if mxs.IsTLSEnabled() { + tlsOpts, err := r.getClientTLSOptions(ctx, mxs) + if err != nil { + return nil, fmt.Errorf("error getting client TLS options: %v", err) + } + opts = append(opts, tlsOpts...) + } return mxsclient.NewClientWithDefaultCredentials(mxs.PodAPIUrl(podIndex), opts...) } @@ -312,9 +385,55 @@ func (r *MaxScaleReconciler) clientWithAPIUrl(ctx context.Context, mxs *mariadbv logger := apiLogger(ctx) opts = append(opts, mdbhttp.WithLogger(&logger)) } + if mxs.IsTLSEnabled() { + tlsOpts, err := r.getClientTLSOptions(ctx, mxs) + if err != nil { + return nil, fmt.Errorf("error getting client TLS options: %v", err) + } + opts = append(opts, tlsOpts...) + } return mxsclient.NewClient(apiUrl, opts...) } +func (r *MaxScaleReconciler) getClientTLSOptions(ctx context.Context, mxs *mariadbv1alpha1.MaxScale) ([]mdbhttp.Option, error) { + if !mxs.IsTLSEnabled() { + return nil, nil + } + tlsCA, err := r.RefResolver.SecretKeyRef(ctx, mxs.TLSCABundleSecretKeyRef(), mxs.Namespace) + if err != nil { + return nil, fmt.Errorf("error reading TLS CA bundle: %v", err) + } + + adminCertKeySelector := mariadbv1alpha1.SecretKeySelector{ + LocalObjectReference: mariadbv1alpha1.LocalObjectReference{ + Name: mxs.TLSAdminCertSecretKey().Name, + }, + Key: pki.TLSCertKey, + } + tlsCert, err := r.RefResolver.SecretKeyRef(ctx, adminCertKeySelector, mxs.Namespace) + if err != nil { + return nil, fmt.Errorf("error reading TLS cert: %v", err) + } + + adminKeyKeySelector := mariadbv1alpha1.SecretKeySelector{ + LocalObjectReference: mariadbv1alpha1.LocalObjectReference{ + Name: mxs.TLSAdminCertSecretKey().Name, + }, + Key: pki.TLSKeyKey, + } + tlsKey, err := r.RefResolver.SecretKeyRef(ctx, adminKeyKeySelector, mxs.Namespace) + if err != nil { + return nil, fmt.Errorf("error reading TLS cert: %v", err) + } + + return []mdbhttp.Option{ + mdbhttp.WithTLSEnabled(mxs.IsTLSEnabled()), + mdbhttp.WithTLSCA([]byte(tlsCA)), + mdbhttp.WithTLSCert([]byte(tlsCert)), + mdbhttp.WithTLSKey([]byte(tlsKey)), + }, nil +} + func apiLogger(ctx context.Context) logr.Logger { return log.FromContext(ctx).WithName("api") } diff --git a/internal/controller/maxscale_controller_metrics.go b/internal/controller/maxscale_controller_metrics.go index 6e6c96f55b..5db4da57fc 100644 --- a/internal/controller/maxscale_controller_metrics.go +++ b/internal/controller/maxscale_controller_metrics.go @@ -3,7 +3,6 @@ package controller import ( "bytes" "context" - "crypto/sha256" "errors" "fmt" "time" @@ -11,6 +10,7 @@ import ( mariadbv1alpha1 "github.com/mariadb-operator/mariadb-operator/api/v1alpha1" "github.com/mariadb-operator/mariadb-operator/pkg/builder" labels "github.com/mariadb-operator/mariadb-operator/pkg/builder/labels" + builderpki "github.com/mariadb-operator/mariadb-operator/pkg/builder/pki" "github.com/mariadb-operator/mariadb-operator/pkg/controller/secret" "github.com/mariadb-operator/mariadb-operator/pkg/metadata" corev1 "k8s.io/api/core/v1" @@ -60,18 +60,31 @@ func (r *MaxScaleReconciler) reconcileExporterConfig(ctx context.Context, req *r if err != nil { return fmt.Errorf("error getting metrics password Secret: %v", err) } - - type tplOpts struct { - User string - Password string - } tpl := createTpl(secretKeyRef.Key, `[maxscale_exporter] maxscale_username={{ .User }} -maxscale_password={{ .Password }}`) +maxscale_password={{ .Password }} +{{- if .TLSEnabled }} +tls_insecure_skip_verify=false +tls_ca_cert_file={{ .TLSCACertPath }} +tls_private_key_file={{ .TLSKeyPath }} +tls_key_cert_file={{ .TLSCertPath }} +{{- end }} +`) buf := new(bytes.Buffer) - err = tpl.Execute(buf, tplOpts{ - User: req.mxs.Spec.Auth.MetricsUsername, - Password: password, + err = tpl.Execute(buf, struct { + User string + Password string + TLSEnabled bool + TLSCACertPath string + TLSKeyPath string + TLSCertPath string + }{ + User: req.mxs.Spec.Auth.MetricsUsername, + Password: password, + TLSEnabled: req.mxs.IsTLSEnabled(), + TLSCACertPath: builderpki.CACertPath, + TLSKeyPath: builderpki.AdminKeyPath, + TLSCertPath: builderpki.AdminCertPath, }) if err != nil { return fmt.Errorf("error rendering exporter config: %v", err) @@ -109,8 +122,19 @@ func (r *MaxScaleReconciler) getExporterPodAnnotations(ctx context.Context, mxs return nil, fmt.Errorf("error getting metrics config Secret: %v", err) } podAnnotations := map[string]string{ - metadata.ConfigAnnotation: fmt.Sprintf("%x", sha256.Sum256([]byte(config))), + metadata.ConfigAnnotation: hash(config), } + + if mxs.IsTLSEnabled() { + tlsAnnotations, err := r.getTLSAdminAnnotations(ctx, mxs) + if err != nil { + return nil, fmt.Errorf("error getting TLS annotations: %v", err) + } + for k, v := range tlsAnnotations { + podAnnotations[k] = v + } + } + return podAnnotations, nil } diff --git a/internal/controller/maxscale_controller_status.go b/internal/controller/maxscale_controller_status.go index 7ce51a431b..56953034f7 100644 --- a/internal/controller/maxscale_controller_status.go +++ b/internal/controller/maxscale_controller_status.go @@ -26,6 +26,7 @@ func (r *MaxScaleReconciler) reconcileStatus(ctx context.Context, req *requestMa return nil }) } + logger := log.FromContext(ctx).WithName("status") var sts appsv1.StatefulSet if err := r.Get(ctx, client.ObjectKeyFromObject(req.mxs), &sts); err != nil { @@ -51,7 +52,7 @@ func (r *MaxScaleReconciler) reconcileStatus(ctx context.Context, req *requestMa newPrimary := ptr.Deref(srvStatus, serverStatus{}).primary if currentPrimary != "" && newPrimary != "" && currentPrimary != newPrimary { - log.FromContext(ctx).Info( + logger.Info( "MaxScale primary server changed", "from-server", currentPrimary, "to-server", newPrimary, @@ -76,8 +77,11 @@ func (r *MaxScaleReconciler) reconcileStatus(ctx context.Context, req *requestMa configSync, err := r.getConfigSyncStatus(ctx, req.mxs, client) errBundle = multierror.Append(errBundle, err) + tlsStatus, err := r.getTLSStatus(ctx, req.mxs) + errBundle = multierror.Append(errBundle, err) + if err := errBundle.ErrorOrNil(); err != nil { - log.FromContext(ctx).V(1).Info("error getting status", "err", err) + logger.V(1).Info("error getting status", "err", err) } return ctrl.Result{}, r.patchStatus(ctx, req.mxs, func(mss *mariadbv1alpha1.MaxScaleStatus) error { @@ -102,6 +106,9 @@ func (r *MaxScaleReconciler) reconcileStatus(ctx context.Context, req *requestMa if configSync != nil { mss.ConfigSync = configSync } + if tlsStatus != nil { + mss.TLS = tlsStatus + } condition.SetReadyWithStatefulSet(mss, &sts) if r.isStatefulSetReady(&sts, req.mxs) { @@ -314,13 +321,21 @@ func (r *MaxScaleReconciler) getSqlClient(ctx context.Context, mxs *mariadbv1alp return nil, fmt.Errorf("error getting sync password: %v", err) } - return sql.NewClient( + opts := []sql.Opt{ sql.WitHost(srv.Address), sql.WithPort(srv.Port), sql.WithDatabase(mxs.Spec.Config.Sync.Database), sql.WithUsername(*mxs.Spec.Auth.SyncUsername), sql.WithPassword(password), - ) + } + if mxs.IsTLSEnabled() { + caBundle, err := r.RefResolver.SecretKeyRef(ctx, mxs.TLSCABundleSecretKeyRef(), mxs.Namespace) + if err != nil { + return nil, fmt.Errorf("error getting CA bundle: %v", err) + } + opts = append(opts, sql.WithMaxscaleTLS(mxs.Name, mxs.Namespace, []byte(caBundle))) + } + return sql.NewClient(opts...) } func (r *MaxScaleReconciler) patchStatus(ctx context.Context, maxscale *mariadbv1alpha1.MaxScale, diff --git a/internal/controller/maxscale_controller_test.go b/internal/controller/maxscale_controller_test.go index d39fd22ce1..595bb59fe8 100644 --- a/internal/controller/maxscale_controller_test.go +++ b/internal/controller/maxscale_controller_test.go @@ -36,6 +36,9 @@ var _ = Describe("MaxScale", func() { Namespace: testMdbkey.Namespace, }, }, + TLS: &mariadbv1alpha1.MaxScaleTLS{ + Enabled: true, + }, }, } Expect(k8sClient.Create(testCtx, &testDefaultMxs)).To(Succeed()) @@ -71,6 +74,9 @@ var _ = Describe("MaxScale", func() { Namespace: testMdbkey.Namespace, }, }, + TLS: &mariadbv1alpha1.MaxScaleTLS{ + Enabled: true, + }, }, } Expect(k8sClient.Create(testCtx, &testSuspendMxs)).To(Succeed()) @@ -140,6 +146,9 @@ var _ = Describe("MaxScale", func() { Namespace: testMdbkey.Namespace, }, }, + TLS: &mariadbv1alpha1.MaxScaleTLS{ + Enabled: true, + }, Metrics: &mariadbv1alpha1.MaxScaleMetrics{ Enabled: true, }, diff --git a/internal/controller/maxscale_controller_tls.go b/internal/controller/maxscale_controller_tls.go new file mode 100644 index 0000000000..9dd7d5fd06 --- /dev/null +++ b/internal/controller/maxscale_controller_tls.go @@ -0,0 +1,258 @@ +package controller + +import ( + "context" + "errors" + "fmt" + + mariadbv1alpha1 "github.com/mariadb-operator/mariadb-operator/api/v1alpha1" + certctrl "github.com/mariadb-operator/mariadb-operator/pkg/controller/certificate" + "github.com/mariadb-operator/mariadb-operator/pkg/controller/secret" + "github.com/mariadb-operator/mariadb-operator/pkg/metadata" + "github.com/mariadb-operator/mariadb-operator/pkg/pki" + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/types" + "k8s.io/utils/ptr" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/log" +) + +func (r *MaxScaleReconciler) reconcileTLS(ctx context.Context, req *requestMaxScale) (ctrl.Result, error) { + if !req.mxs.IsTLSEnabled() { + return ctrl.Result{}, nil + } + if err := r.reconcileTLSCerts(ctx, req.mxs); err != nil { + return ctrl.Result{}, err + } + if err := r.reconcileTLSCABundle(ctx, req.mxs); err != nil { + return ctrl.Result{}, err + } + return ctrl.Result{}, nil +} + +func (r *MaxScaleReconciler) reconcileTLSCerts(ctx context.Context, mxs *mariadbv1alpha1.MaxScale) error { + tls := ptr.Deref(mxs.Spec.TLS, mariadbv1alpha1.MaxScaleTLS{}) + + // MaxScale TLS can't communicate with a non-TLS MariaDB server + if tls.ServerCASecretRef == nil || tls.ServerCertSecretRef == nil { + return errors.New("'spec.tls.serverCASecretRef' and 'spec.tls.ServerCertSecretRef' fields" + + "must be set in order to communicate with MariaDB server") + } + + adminCertOpts := []certctrl.CertReconcilerOpt{ + certctrl.WithCABundle(mxs.TLSCABundleSecretKeyRef(), mxs.Namespace), + certctrl.WithCA( + tls.AdminCASecretRef == nil, + mxs.TLSAdminCASecretKey(), + ), + certctrl.WithCert( + tls.AdminCertSecretRef == nil, + mxs.TLSAdminCertSecretKey(), + mxs.TLSAdminDNSNames(), + ), + certctrl.WithServerCertKeyUsage(), + certctrl.WithCertIssuerRef(tls.AdminCertIssuerRef), + certctrl.WithRelatedObject(mxs), + } + if _, err := r.CertReconciler.Reconcile(ctx, adminCertOpts...); err != nil { + return fmt.Errorf("error reconciling admin cert: %v", err) + } + + listenerCertOpts := []certctrl.CertReconcilerOpt{ + certctrl.WithCABundle(mxs.TLSCABundleSecretKeyRef(), mxs.Namespace), + certctrl.WithCA( + tls.ListenerCASecretRef == nil, + mxs.TLSAdminCASecretKey(), + ), + certctrl.WithCert( + tls.ListenerCertSecretRef == nil, + mxs.TLSListenerCertSecretKey(), + mxs.TLSListenerDNSNames(), + ), + certctrl.WithServerCertKeyUsage(), + certctrl.WithCertIssuerRef(tls.ListenerCertIssuerRef), + certctrl.WithRelatedObject(mxs), + } + if _, err := r.CertReconciler.Reconcile(ctx, listenerCertOpts...); err != nil { + return fmt.Errorf("error reconciling listener cert: %v", err) + } + + return nil +} + +func (r *MaxScaleReconciler) reconcileTLSCABundle(ctx context.Context, mxs *mariadbv1alpha1.MaxScale) error { + logger := log.FromContext(ctx).WithName("ca-bundle") + + caBundleKeySelector := mxs.TLSCABundleSecretKeyRef() + adminCAKeySelector := mariadbv1alpha1.SecretKeySelector{ + LocalObjectReference: mariadbv1alpha1.LocalObjectReference{ + Name: mxs.TLSAdminCASecretKey().Name, + }, + Key: pki.CACertKey, + } + listenerCAKeySelector := mariadbv1alpha1.SecretKeySelector{ + LocalObjectReference: mariadbv1alpha1.LocalObjectReference{ + Name: mxs.TLSListenerCASecretKey().Name, + }, + Key: pki.CACertKey, + } + serverCAKeySelector := mariadbv1alpha1.SecretKeySelector{ + LocalObjectReference: mariadbv1alpha1.LocalObjectReference{ + Name: mxs.TLSServerCASecretKey().Name, + }, + Key: pki.CACertKey, + } + caKeySelectors := []mariadbv1alpha1.SecretKeySelector{ + caBundleKeySelector, + adminCAKeySelector, + listenerCAKeySelector, + serverCAKeySelector, + } + var caBundles [][]byte + + for _, caKeySelector := range caKeySelectors { + ca, err := r.RefResolver.SecretKeyRef(ctx, caKeySelector, mxs.Namespace) + if err != nil { + if !apierrors.IsNotFound(err) { + return fmt.Errorf("error getting CA Secret \"%s\": %v", caKeySelector.Name, err) + } + logger.V(1).Info("CA Secret not found", "secret-name", caKeySelector.Name) + } + caBundles = append(caBundles, []byte(ca)) + } + + bundle, err := pki.BundleCertificatePEMs( + caBundles, + pki.WithLogger(logger), + pki.WithSkipExpired(true), + ) + if err != nil { + return fmt.Errorf("error creating CA bundle: %v", err) + } + + secretReq := secret.SecretRequest{ + Metadata: []*mariadbv1alpha1.Metadata{mxs.Spec.InheritMetadata}, + Owner: mxs, + Key: types.NamespacedName{ + Name: caBundleKeySelector.Name, + Namespace: mxs.Namespace, + }, + Data: map[string][]byte{ + caBundleKeySelector.Key: bundle, + }, + } + return r.SecretReconciler.Reconcile(ctx, &secretReq) +} + +func (r *MaxScaleReconciler) getTLSAnnotations(ctx context.Context, mxs *mariadbv1alpha1.MaxScale) (map[string]string, error) { + if !mxs.IsTLSEnabled() { + return nil, nil + } + annotations, err := r.getTLSAdminAnnotations(ctx, mxs) + if err != nil { + return nil, fmt.Errorf("error getting client annotations: %v", err) + } + + secretSelectorsByAnn := map[string]mariadbv1alpha1.SecretKeySelector{ + metadata.TLSListenerCertAnnotation: { + LocalObjectReference: mariadbv1alpha1.LocalObjectReference{ + Name: mxs.TLSListenerCertSecretKey().Name, + }, + Key: pki.TLSCertKey, + }, + metadata.TLSServerCertAnnotation: { + LocalObjectReference: mariadbv1alpha1.LocalObjectReference{ + Name: mxs.TLSServerCertSecretKey().Name, + }, + Key: pki.TLSCertKey, + }, + } + + for annotation, secretKeySelector := range secretSelectorsByAnn { + cert, err := r.RefResolver.SecretKeyRef(ctx, secretKeySelector, mxs.Namespace) + if err != nil { + return nil, fmt.Errorf("error getting Secret \"%s\": %v", secretKeySelector.Name, err) + } + annotations[annotation] = hash(cert) + } + + return annotations, nil +} + +func (r *MaxScaleReconciler) getTLSAdminAnnotations(ctx context.Context, mxs *mariadbv1alpha1.MaxScale) (map[string]string, error) { + if !mxs.IsTLSEnabled() { + return nil, nil + } + annotations := make(map[string]string) + + ca, err := r.RefResolver.SecretKeyRef(ctx, mxs.TLSCABundleSecretKeyRef(), mxs.Namespace) + if err != nil { + return nil, fmt.Errorf("error getting CA bundle: %v", err) + } + annotations[metadata.TLSCAAnnotation] = hash(ca) + + adminCertKeySelector := mariadbv1alpha1.SecretKeySelector{ + LocalObjectReference: mariadbv1alpha1.LocalObjectReference{ + Name: mxs.TLSAdminCertSecretKey().Name, + }, + Key: pki.TLSCertKey, + } + adminCert, err := r.RefResolver.SecretKeyRef(ctx, adminCertKeySelector, mxs.Namespace) + if err != nil { + return nil, fmt.Errorf("error getting admin cert: %v", err) + } + annotations[metadata.TLSAdminCertAnnotation] = hash(adminCert) + + return annotations, nil +} + +func (r *MaxScaleReconciler) getTLSStatus(ctx context.Context, mxs *mariadbv1alpha1.MaxScale) (*mariadbv1alpha1.MaxScaleTLSStatus, error) { + if !mxs.IsTLSEnabled() { + return nil, nil + } + var tlsStatus mariadbv1alpha1.MaxScaleTLSStatus + + certStatus, err := getCertificateStatus(ctx, r.RefResolver, mxs.TLSCABundleSecretKeyRef(), mxs.Namespace) + if err != nil { + return nil, fmt.Errorf("error getting CA bundle status: %v", err) + } + tlsStatus.CABundle = certStatus + + secretKeySelector := mariadbv1alpha1.SecretKeySelector{ + LocalObjectReference: mariadbv1alpha1.LocalObjectReference{ + Name: mxs.TLSAdminCertSecretKey().Name, + }, + Key: pki.TLSCertKey, + } + certStatus, err = getCertificateStatus(ctx, r.RefResolver, secretKeySelector, mxs.Namespace) + if err != nil { + return nil, fmt.Errorf("error getting admin certificate status: %v", err) + } + tlsStatus.AdminCert = ptr.To(certStatus[0]) + + secretKeySelector = mariadbv1alpha1.SecretKeySelector{ + LocalObjectReference: mariadbv1alpha1.LocalObjectReference{ + Name: mxs.TLSListenerCertSecretKey().Name, + }, + Key: pki.TLSCertKey, + } + certStatus, err = getCertificateStatus(ctx, r.RefResolver, secretKeySelector, mxs.Namespace) + if err != nil { + return nil, fmt.Errorf("error getting listener certificate status: %v", err) + } + tlsStatus.ListenerCert = ptr.To(certStatus[0]) + + secretKeySelector = mariadbv1alpha1.SecretKeySelector{ + LocalObjectReference: mariadbv1alpha1.LocalObjectReference{ + Name: mxs.TLSServerCertSecretKey().Name, + }, + Key: pki.TLSCertKey, + } + certStatus, err = getCertificateStatus(ctx, r.RefResolver, secretKeySelector, mxs.Namespace) + if err != nil { + return nil, fmt.Errorf("error getting server certificate status: %v", err) + } + tlsStatus.ServerCert = ptr.To(certStatus[0]) + + return &tlsStatus, nil +} diff --git a/internal/controller/suite_test.go b/internal/controller/suite_test.go index 2a269e28bd..744ad7588b 100644 --- a/internal/controller/suite_test.go +++ b/internal/controller/suite_test.go @@ -7,12 +7,14 @@ import ( "testing" "time" + certmanagerv1 "github.com/cert-manager/cert-manager/pkg/apis/certmanager/v1" "github.com/go-logr/logr" mariadbv1alpha1 "github.com/mariadb-operator/mariadb-operator/api/v1alpha1" "github.com/mariadb-operator/mariadb-operator/pkg/builder" condition "github.com/mariadb-operator/mariadb-operator/pkg/condition" "github.com/mariadb-operator/mariadb-operator/pkg/controller/auth" "github.com/mariadb-operator/mariadb-operator/pkg/controller/batch" + certctrl "github.com/mariadb-operator/mariadb-operator/pkg/controller/certificate" "github.com/mariadb-operator/mariadb-operator/pkg/controller/configmap" "github.com/mariadb-operator/mariadb-operator/pkg/controller/deployment" "github.com/mariadb-operator/mariadb-operator/pkg/controller/endpoints" @@ -73,11 +75,9 @@ var _ = BeforeSuite(func() { Expect(err).NotTo(HaveOccurred()) Expect(testCidrPrefix).NotTo(BeEmpty()) - err = mariadbv1alpha1.AddToScheme(scheme.Scheme) - Expect(err).NotTo(HaveOccurred()) - - err = monitoringv1.AddToScheme(scheme.Scheme) - Expect(err).NotTo(HaveOccurred()) + Expect(mariadbv1alpha1.AddToScheme(scheme.Scheme)).NotTo(HaveOccurred()) + Expect(monitoringv1.AddToScheme(scheme.Scheme)).NotTo(HaveOccurred()) + Expect(certmanagerv1.AddToScheme(scheme.Scheme)).NotTo(HaveOccurred()) //+kubebuilder:scaffold:scheme @@ -145,9 +145,10 @@ var _ = BeforeSuite(func() { rbacReconciler := rbac.NewRBACReconiler(client, builder) deployReconciler := deployment.NewDeploymentReconciler(client) svcMonitorReconciler := servicemonitor.NewServiceMonitorReconciler(client) + certReconciler := certctrl.NewCertReconciler(client, scheme, k8sManager.GetEventRecorderFor("cert"), disc, builder) mxsReconciler := maxscale.NewMaxScaleReconciler(client, builder, env) - replConfig := replication.NewReplicationConfig(client, builder, secretReconciler) + replConfig := replication.NewReplicationConfig(client, builder, secretReconciler, env) replicationReconciler, err := replication.NewReplicationReconciler( client, replRecorder, @@ -216,6 +217,7 @@ var _ = BeforeSuite(func() { AuthReconciler: authReconciler, DeploymentReconciler: deployReconciler, ServiceMonitorReconciler: svcMonitorReconciler, + CertReconciler: certReconciler, MaxScaleReconciler: mxsReconciler, ReplicationReconciler: replicationReconciler, @@ -241,6 +243,7 @@ var _ = BeforeSuite(func() { ServiceReconciler: serviceReconciler, DeploymentReconciler: deployReconciler, ServiceMonitorReconciler: svcMonitorReconciler, + CertReconciler: certReconciler, SuspendEnabled: false, @@ -324,10 +327,10 @@ var _ = BeforeSuite(func() { k8sManager.Elected(), testCASecretKey, "test", - 4*365*24*time.Hour, + 3*365*24*time.Hour, testCertSecretKey, - 365*24*time.Hour, - 90*24*time.Hour, + 3*30*24*time.Hour, + 33, testWebhookServiceKey, 5*time.Minute, ).SetupWithManager(k8sManager) diff --git a/internal/controller/user_controller.go b/internal/controller/user_controller.go index 032aba25e6..3f88829fa4 100644 --- a/internal/controller/user_controller.go +++ b/internal/controller/user_controller.go @@ -7,14 +7,9 @@ import ( mariadbv1alpha1 "github.com/mariadb-operator/mariadb-operator/api/v1alpha1" condition "github.com/mariadb-operator/mariadb-operator/pkg/condition" "github.com/mariadb-operator/mariadb-operator/pkg/controller/sql" - "github.com/mariadb-operator/mariadb-operator/pkg/metadata" - "github.com/mariadb-operator/mariadb-operator/pkg/predicate" "github.com/mariadb-operator/mariadb-operator/pkg/refresolver" sqlClient "github.com/mariadb-operator/mariadb-operator/pkg/sql" - "github.com/mariadb-operator/mariadb-operator/pkg/watch" - corev1 "k8s.io/api/core/v1" ctrl "sigs.k8s.io/controller-runtime" - ctrlbuilder "sigs.k8s.io/controller-runtime/pkg/builder" "sigs.k8s.io/controller-runtime/pkg/client" ctrlClient "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/log" @@ -67,18 +62,8 @@ func (r *UserReconciler) SetupWithManager(ctx context.Context, mgr ctrl.Manager) builder := ctrl.NewControllerManagedBy(mgr). For(&mariadbv1alpha1.User{}) - watcherIndexer := watch.NewWatcherIndexer(mgr, builder, r.Client) - if err := watcherIndexer.Watch( - ctx, - &corev1.Secret{}, - &mariadbv1alpha1.User{}, - &mariadbv1alpha1.UserList{}, - mariadbv1alpha1.UserPasswordSecretFieldPath, - ctrlbuilder.WithPredicates( - predicate.PredicateWithLabel(metadata.WatchLabel), - ), - ); err != nil { - return fmt.Errorf("error watching: %v", err) + if err := mariadbv1alpha1.IndexUser(ctx, mgr, builder, r.Client); err != nil { + return fmt.Errorf("error indexing User: %v", err) } return builder.Complete(r) @@ -140,6 +125,11 @@ func (wr *wrappedUserReconciler) Reconcile(ctx context.Context, mdbClient *sqlCl } createUserOpts = append(createUserOpts, sqlClient.WithIdentifiedBy(password)) } + + if wr.user.Spec.Require != nil { + createUserOpts = append(createUserOpts, sqlClient.WithTLSRequirements(wr.user.Spec.Require)) + } + createUserOpts = append(createUserOpts, sqlClient.WithMaxUserConnections(wr.user.Spec.MaxUserConnections)) username := wr.user.UsernameOrDefault() diff --git a/internal/controller/user_controller_test.go b/internal/controller/user_controller_test.go index 0783e10471..c07dfa0beb 100644 --- a/internal/controller/user_controller_test.go +++ b/internal/controller/user_controller_test.go @@ -35,13 +35,9 @@ var _ = Describe("User", func() { }, WaitForIt: true, }, - PasswordSecretKeyRef: &mariadbv1alpha1.SecretKeySelector{ - LocalObjectReference: mariadbv1alpha1.LocalObjectReference{ - Name: testPwdKey.Name, - }, - Key: testPwdSecretKey, - }, - MaxUserConnections: 20, + PasswordSecretKeyRef: &testPasswordSecretRef, + Require: testTLSRequirements, + MaxUserConnections: 20, }, } Expect(k8sClient.Create(testCtx, &user)).To(Succeed()) @@ -64,6 +60,9 @@ var _ = Describe("User", func() { } return controllerutil.ContainsFinalizer(&user, userFinalizerName) }, testTimeout, testInterval).Should(BeTrue()) + + By("Expecting credentials to be valid") + testConnection(user.Name, testPasswordSecretRef, testTLSClientCertRef, testDatabase, true) }) It("should update password", func() { @@ -104,13 +103,9 @@ var _ = Describe("User", func() { }, WaitForIt: true, }, - PasswordSecretKeyRef: &mariadbv1alpha1.SecretKeySelector{ - LocalObjectReference: mariadbv1alpha1.LocalObjectReference{ - Name: key.Name, - }, - Key: secretKey, - }, - MaxUserConnections: 20, + PasswordSecretKeyRef: &testPasswordSecretRef, + Require: testTLSRequirements, + MaxUserConnections: 20, }, } Expect(k8sClient.Create(testCtx, &user)).To(Succeed()) @@ -127,7 +122,7 @@ var _ = Describe("User", func() { }, testTimeout, testInterval).Should(BeTrue()) By("Expecting credentials to be valid") - testConnection(user.Name, *user.Spec.PasswordSecretKeyRef, testDatabase, true) + testConnection(user.Name, testPasswordSecretRef, testTLSClientCertRef, testDatabase, true) By("Updating password Secret") Eventually(func(g Gomega) bool { @@ -138,7 +133,7 @@ var _ = Describe("User", func() { }, testTimeout, testInterval).Should(BeTrue()) By("Expecting credentials to be valid") - testConnection(user.Name, *user.Spec.PasswordSecretKeyRef, testDatabase, true) + testConnection(user.Name, testPasswordSecretRef, testTLSClientCertRef, testDatabase, true) }) It("should update password hash", func() { @@ -149,13 +144,6 @@ var _ = Describe("User", func() { secretKeyPassword := "password" secretKeyHash := "passwordHash" - PasswordSecretKeyRef := &mariadbv1alpha1.SecretKeySelector{ - LocalObjectReference: mariadbv1alpha1.LocalObjectReference{ - Name: key.Name, - }, - Key: secretKeyPassword, - } - By("Creating Secret") secret := corev1.Secret{ ObjectMeta: metav1.ObjectMeta{ @@ -188,13 +176,9 @@ var _ = Describe("User", func() { }, WaitForIt: true, }, - PasswordHashSecretKeyRef: &mariadbv1alpha1.SecretKeySelector{ - LocalObjectReference: mariadbv1alpha1.LocalObjectReference{ - Name: key.Name, - }, - Key: secretKeyHash, - }, - MaxUserConnections: 20, + PasswordSecretKeyRef: &testPasswordSecretRef, + Require: testTLSRequirements, + MaxUserConnections: 20, }, } Expect(k8sClient.Create(testCtx, &user)).To(Succeed()) @@ -211,7 +195,7 @@ var _ = Describe("User", func() { }, testTimeout, testInterval).Should(BeTrue()) By("Expecting credentials to be valid") - testConnection(user.Name, *PasswordSecretKeyRef, testDatabase, true) + testConnection(user.Name, testPasswordSecretRef, testTLSClientCertRef, testDatabase, true) By("Updating password Secret") Eventually(func(g Gomega) bool { @@ -223,7 +207,7 @@ var _ = Describe("User", func() { }, testTimeout, testInterval).Should(BeTrue()) By("Expecting credentials to be valid") - testConnection(user.Name, *PasswordSecretKeyRef, testDatabase, true) + testConnection(user.Name, testPasswordSecretRef, testTLSClientCertRef, testDatabase, true) }) It("should update password plugin", func() { @@ -235,13 +219,6 @@ var _ = Describe("User", func() { secretKeyPluginName := "pluginName" secretKeyPluginArg := "pluginArg" - PasswordSecretKeyRef := &mariadbv1alpha1.SecretKeySelector{ - LocalObjectReference: mariadbv1alpha1.LocalObjectReference{ - Name: key.Name, - }, - Key: secretKeyPassword, - } - By("Creating Secret") secret := corev1.Secret{ ObjectMeta: metav1.ObjectMeta{ @@ -289,6 +266,7 @@ var _ = Describe("User", func() { Key: secretKeyPluginArg, }, }, + Require: testTLSRequirements, MaxUserConnections: 20, }, } @@ -306,7 +284,7 @@ var _ = Describe("User", func() { }, testTimeout, testInterval).Should(BeTrue()) By("Expecting credentials to be valid") - testConnection(user.Name, *PasswordSecretKeyRef, testDatabase, true) + testConnection(user.Name, testPasswordSecretRef, testTLSClientCertRef, testDatabase, true) By("Updating password Secret") Eventually(func(g Gomega) bool { @@ -318,7 +296,7 @@ var _ = Describe("User", func() { }, testTimeout, testInterval).Should(BeTrue()) By("Expecting credentials to be valid") - testConnection(user.Name, *PasswordSecretKeyRef, testDatabase, true) + testConnection(user.Name, testPasswordSecretRef, testTLSClientCertRef, testDatabase, true) }) It("should clean up", func() { @@ -327,12 +305,6 @@ var _ = Describe("User", func() { Name: "test-clean-up-user", Namespace: testNamespace, } - passwordSecretKeyRef := mariadbv1alpha1.SecretKeySelector{ - LocalObjectReference: mariadbv1alpha1.LocalObjectReference{ - Name: testPwdKey.Name, - }, - Key: testPwdSecretKey, - } user := mariadbv1alpha1.User{ ObjectMeta: metav1.ObjectMeta{ Name: userKey.Name, @@ -348,7 +320,8 @@ var _ = Describe("User", func() { }, WaitForIt: true, }, - PasswordSecretKeyRef: &passwordSecretKeyRef, + PasswordSecretKeyRef: &testPasswordSecretRef, + Require: testTLSRequirements, MaxUserConnections: 20, }, } @@ -419,7 +392,7 @@ var _ = Describe("User", func() { }) By("Expecting credentials to be valid") - testConnection(userKey.Name, passwordSecretKeyRef, databaseKey.Name, true) + testConnection(userKey.Name, testPasswordSecretRef, testTLSClientCertRef, databaseKey.Name, true) By("Deleting Grant") Expect(k8sClient.Delete(testCtx, &grant)).To(Succeed()) @@ -434,7 +407,7 @@ var _ = Describe("User", func() { expectToNotExist(testCtx, k8sClient, &user) By("Expecting credentials to be invalid") - testConnection(userKey.Name, passwordSecretKeyRef, databaseKey.Name, false) + testConnection(userKey.Name, testPasswordSecretRef, testTLSClientCertRef, databaseKey.Name, false) }) It("should skip clean up", func() { @@ -443,12 +416,6 @@ var _ = Describe("User", func() { Name: "test-skip-clean-up-user", Namespace: testNamespace, } - passwordSecretKeyRef := mariadbv1alpha1.SecretKeySelector{ - LocalObjectReference: mariadbv1alpha1.LocalObjectReference{ - Name: testPwdKey.Name, - }, - Key: testPwdSecretKey, - } user := mariadbv1alpha1.User{ ObjectMeta: metav1.ObjectMeta{ Name: userKey.Name, @@ -464,7 +431,8 @@ var _ = Describe("User", func() { }, WaitForIt: true, }, - PasswordSecretKeyRef: &passwordSecretKeyRef, + PasswordSecretKeyRef: &testPasswordSecretRef, + Require: testTLSRequirements, MaxUserConnections: 20, }, } @@ -535,7 +503,7 @@ var _ = Describe("User", func() { }) By("Expecting credentials to be valid") - testConnection(userKey.Name, passwordSecretKeyRef, databaseKey.Name, true) + testConnection(userKey.Name, testPasswordSecretRef, testTLSClientCertRef, databaseKey.Name, true) By("Deleting Grant") Expect(k8sClient.Delete(testCtx, &grant)).To(Succeed()) @@ -550,6 +518,6 @@ var _ = Describe("User", func() { expectToNotExist(testCtx, k8sClient, &user) By("Expecting credentials to be valid") - testConnection(userKey.Name, passwordSecretKeyRef, databaseKey.Name, true) + testConnection(userKey.Name, testPasswordSecretRef, testTLSClientCertRef, databaseKey.Name, true) }) }) diff --git a/internal/controller/utils_test.go b/internal/controller/utils_test.go index 614c74d633..9cd2d78dcf 100644 --- a/internal/controller/utils_test.go +++ b/internal/controller/utils_test.go @@ -50,8 +50,17 @@ var ( testPwdSecretKey = "passsword" testPwdMetricsSecretKey = "metrics" testUser = "test" - testDatabase = "test" - testConnKey = types.NamespacedName{ + testPasswordSecretRef = mariadbv1alpha1.SecretKeySelector{ + LocalObjectReference: mariadbv1alpha1.LocalObjectReference{ + Name: testPwdKey.Name, + }, + Key: testPwdSecretKey, + } + testTLSClientCARef *mariadbv1alpha1.LocalObjectReference + testTLSClientCertRef *mariadbv1alpha1.LocalObjectReference + testTLSRequirements *mariadbv1alpha1.TLSRequirements + testDatabase = "test" + testConnKey = types.NamespacedName{ Name: "conn", Namespace: testNamespace, } @@ -134,12 +143,7 @@ func testCreateInitialData(ctx context.Context, env environment.OperatorEnv) { }, Username: &testUser, PasswordSecretKeyRef: &mariadbv1alpha1.GeneratedSecretKeyRef{ - SecretKeySelector: mariadbv1alpha1.SecretKeySelector{ - LocalObjectReference: mariadbv1alpha1.LocalObjectReference{ - Name: testPwdKey.Name, - }, - Key: testPwdSecretKey, - }, + SecretKeySelector: testPasswordSecretRef, }, Database: &testDatabase, Connection: &mariadbv1alpha1.ConnectionTemplate{ @@ -192,10 +196,24 @@ max_allowed_packet=256M`), Storage: mariadbv1alpha1.Storage{ Size: ptr.To(resource.MustParse("300Mi")), }, + TLS: &mariadbv1alpha1.TLS{ + Enabled: true, + }, }, } applyMariadbTestConfig(&mdb) + testTLSClientCARef = &mariadbv1alpha1.LocalObjectReference{ + Name: mdb.TLSClientCASecretKey().Name, + } + testTLSClientCertRef = &mariadbv1alpha1.LocalObjectReference{ + Name: mdb.TLSClientCertSecretKey().Name, + } + testTLSRequirements = &mariadbv1alpha1.TLSRequirements{ + Issuer: ptr.To(fmt.Sprintf("/CN=%s", testTLSClientCARef.Name)), + Subject: ptr.To(fmt.Sprintf("/CN=%s", testTLSClientCertRef.Name)), + } + Expect(k8sClient.Create(ctx, &mdb)).To(Succeed()) expectMariadbReady(ctx, k8sClient, testMdbkey) } @@ -204,7 +222,7 @@ func testCleanupInitialData(ctx context.Context) { var password corev1.Secret Expect(k8sClient.Get(ctx, testPwdKey, &password)).To(Succeed()) Expect(k8sClient.Delete(ctx, &password)).To(Succeed()) - deleteMariadb(testMdbkey) + deleteMariadb(testMdbkey, false) } func testMariadbUpdate(mdb *mariadbv1alpha1.MariaDB) { @@ -497,7 +515,8 @@ func testMaxscale(mdb *mariadbv1alpha1.MariaDB, mxs *mariadbv1alpha1.MaxScale) { } } -func testConnection(username string, passwordSecretKeyRef mariadbv1alpha1.SecretKeySelector, database string, isValid bool) { +func testConnection(username string, password mariadbv1alpha1.SecretKeySelector, clientCert *mariadbv1alpha1.LocalObjectReference, + database string, isValid bool) { key := types.NamespacedName{ Name: fmt.Sprintf("test-creds-conn-%s", uuid.New().String()), Namespace: testNamespace, @@ -518,9 +537,10 @@ func testConnection(username string, passwordSecretKeyRef mariadbv1alpha1.Secret }, WaitForIt: true, }, - Username: username, - PasswordSecretKeyRef: passwordSecretKeyRef, - Database: &database, + Username: username, + PasswordSecretKeyRef: password, + TLSClientCertSecretRef: clientCert, + Database: &database, }, } By("Creating Connection") @@ -592,7 +612,7 @@ func getS3WithBucket(bucket, prefix string) *mariadbv1alpha1.S3 { }, Key: "secret-access-key", }, - TLS: &mariadbv1alpha1.TLS{ + TLS: &mariadbv1alpha1.TLSS3{ Enabled: true, CASecretKeyRef: &mariadbv1alpha1.SecretKeySelector{ LocalObjectReference: mariadbv1alpha1.LocalObjectReference{ @@ -707,7 +727,7 @@ func deploymentReady(deploy *appsv1.Deployment) bool { return false } -func deleteMariadb(key types.NamespacedName) { +func deleteMariadb(key types.NamespacedName, assertPVCDeletion bool) { var mdb mariadbv1alpha1.MariaDB By("Deleting MariaDB") Expect(k8sClient.Get(testCtx, key, &mdb)).To(Succeed()) @@ -724,6 +744,25 @@ func deleteMariadb(key types.NamespacedName) { } Expect(k8sClient.DeleteAllOf(testCtx, &corev1.PersistentVolumeClaim{}, opts...)).To(Succeed()) + if !assertPVCDeletion { + return + } + Eventually(func(g Gomega) bool { + listOpts := &client.ListOptions{ + LabelSelector: klabels.SelectorFromSet( + labels.NewLabelsBuilder(). + WithMariaDBSelectorLabels(&mdb). + Build(), + ), + Namespace: mdb.GetNamespace(), + } + pvcList := &corev1.PersistentVolumeClaimList{} + err := k8sClient.List(testCtx, pvcList, listOpts) + if err != nil && !apierrors.IsNotFound(err) { + g.Expect(err).ToNot(HaveOccurred()) + } + return len(pvcList.Items) == 0 + }, testHighTimeout, testInterval).Should(BeTrue()) } func deleteMaxScale(key types.NamespacedName, assertPVCDeletion bool) { diff --git a/internal/controller/webhookconfig_controller.go b/internal/controller/webhookconfig_controller.go index b221786c10..c1b5e38428 100644 --- a/internal/controller/webhookconfig_controller.go +++ b/internal/controller/webhookconfig_controller.go @@ -14,6 +14,7 @@ import ( certctrl "github.com/mariadb-operator/mariadb-operator/pkg/controller/certificate" "github.com/mariadb-operator/mariadb-operator/pkg/health" "github.com/mariadb-operator/mariadb-operator/pkg/metadata" + "github.com/mariadb-operator/mariadb-operator/pkg/pki" "github.com/mariadb-operator/mariadb-operator/pkg/predicate" admissionregistration "k8s.io/api/admissionregistration/v1" v1 "k8s.io/api/core/v1" @@ -32,6 +33,7 @@ type WebhookConfigReconciler struct { scheme *runtime.Scheme recorder record.EventRecorder certReconciler *certctrl.CertReconciler + certOpts []certctrl.CertReconcilerOpt serviceKey types.NamespacedName requeueDuration time.Duration leaderChan <-chan struct{} @@ -41,26 +43,31 @@ type WebhookConfigReconciler struct { } func NewWebhookConfigReconciler(client client.Client, scheme *runtime.Scheme, recorder record.EventRecorder, leaderChan <-chan struct{}, - caSecretKey types.NamespacedName, caCommonName string, caValidity time.Duration, - certSecretKey types.NamespacedName, certValidity time.Duration, lookaheadValidity time.Duration, + caSecretKey types.NamespacedName, caCommonName string, caLifetime time.Duration, + certSecretKey types.NamespacedName, certLifetime time.Duration, renewBeforePercentage int32, serviceKey types.NamespacedName, requeueDuration time.Duration) *WebhookConfigReconciler { - certDNSnames := serviceDNSNames(serviceKey) - return &WebhookConfigReconciler{ - Client: client, - scheme: scheme, - recorder: recorder, - certReconciler: certctrl.NewCertReconciler( - client, - caSecretKey, - caCommonName, - certSecretKey, - certDNSnames.CommonName, - certDNSnames.Names, - certctrl.WithCAValidity(caValidity), - certctrl.WithCertValidity(certValidity), - certctrl.WithLookaheadValidity(lookaheadValidity), + certOpts := []certctrl.CertReconcilerOpt{ + certctrl.WithCA(true, caSecretKey), + certctrl.WithCACommonName(caCommonName), + certctrl.WithCALifetime(caLifetime), + certctrl.WithCASecretType(certctrl.SecretTypeTLS), + certctrl.WithCert(true, certSecretKey, serviceDNSNames(serviceKey).Names), + certctrl.WithCertLifetime(certLifetime), + certctrl.WithServerCertKeyUsage(), + certctrl.WithSupportedPrivateKeys( + pki.PrivateKeyTypeECDSA, + pki.PrivateKeyTypeRSA, // backwards compatibility with webhook certs from previous versions ), + certctrl.WithRenewBeforePercentage(renewBeforePercentage), + } + + return &WebhookConfigReconciler{ + Client: client, + scheme: scheme, + recorder: recorder, + certReconciler: certctrl.NewCertReconciler(client, scheme, recorder, nil, nil), + certOpts: certOpts, serviceKey: serviceKey, requeueDuration: requeueDuration, leaderChan: leaderChan, @@ -71,7 +78,7 @@ func NewWebhookConfigReconciler(client client.Client, scheme *runtime.Scheme, re } func (r *WebhookConfigReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { - certResult, err := r.certReconciler.Reconcile(ctx) + certResult, err := r.certReconciler.Reconcile(ctx, r.certOpts...) if err != nil { return ctrl.Result{}, fmt.Errorf("Error reconciling webhook certificate: %v", err) } diff --git a/internal/controller/webhookconfig_controller_test.go b/internal/controller/webhookconfig_controller_test.go index 97716a74b0..e1c65e1e7b 100644 --- a/internal/controller/webhookconfig_controller_test.go +++ b/internal/controller/webhookconfig_controller_test.go @@ -84,7 +84,7 @@ var _ = Describe("WebhookConfig", func() { }, testTimeout, testInterval).Should(BeTrue()) By("Expecting to get CA KeyPair") - caKeyPair, err := pki.KeyPairFromTLSSecret(&caSecret) + caKeyPair, err := pki.NewKeyPairFromTLSSecret(&caSecret) Expect(err).ToNot(HaveOccurred()) Expect(caKeyPair).NotTo(BeNil()) DeferCleanup(func() { @@ -92,7 +92,7 @@ var _ = Describe("WebhookConfig", func() { }) By("Expecting to get certificate KeyPair") - certKeyPair, err := pki.KeyPairFromTLSSecret(&certSecret) + certKeyPair, err := pki.NewKeyPairFromTLSSecret(&certSecret) Expect(err).ToNot(HaveOccurred()) Expect(certKeyPair).NotTo(BeNil()) DeferCleanup(func() { @@ -101,7 +101,9 @@ var _ = Describe("WebhookConfig", func() { By("Expecting certificate to be valid") dnsNames := serviceDNSNames(testWebhookServiceKey) - valid, err := pki.ValidCert(caKeyPair.Cert, certKeyPair, dnsNames.CommonName, time.Now()) + caCerts, err := caKeyPair.Certificates() + Expect(err).ToNot(HaveOccurred()) + valid, err := pki.ValidateCert(caCerts, certKeyPair, dnsNames.CommonName, time.Now()) Expect(err).ToNot(HaveOccurred()) Expect(valid).To(BeTrue()) @@ -208,18 +210,20 @@ var _ = Describe("WebhookConfig", func() { }, testTimeout, testInterval).Should(BeTrue()) By("Expecting to get CA KeyPair") - caKeyPair, err = pki.KeyPairFromTLSSecret(&caSecret) + caKeyPair, err = pki.NewKeyPairFromTLSSecret(&caSecret) Expect(err).ToNot(HaveOccurred()) Expect(caKeyPair).NotTo(BeNil()) By("Expecting to get certificate KeyPair") - certKeyPair, err = pki.KeyPairFromTLSSecret(&certSecret) + certKeyPair, err = pki.NewKeyPairFromTLSSecret(&certSecret) Expect(err).ToNot(HaveOccurred()) Expect(certKeyPair).NotTo(BeNil()) By("Expecting certificate to be valid") dnsNames = serviceDNSNames(testWebhookServiceKey) - valid, err = pki.ValidCert(caKeyPair.Cert, certKeyPair, dnsNames.CommonName, time.Now()) + caCerts, err = caKeyPair.Certificates() + Expect(err).ToNot(HaveOccurred()) + valid, err = pki.ValidateCert(caCerts, certKeyPair, dnsNames.CommonName, time.Now()) Expect(err).ToNot(HaveOccurred()) Expect(valid).To(BeTrue()) }) diff --git a/make/deploy.mk b/make/deploy.mk index 0f1f1f2bc1..06d9b8724c 100644 --- a/make/deploy.mk +++ b/make/deploy.mk @@ -92,7 +92,7 @@ install-prometheus-crds: cluster-ctx ## Install Prometheus CRDs. install-prometheus: cluster-ctx ## Install kube-prometheus-stack helm chart. @PROMETHEUS_VERSION=$(PROMETHEUS_VERSION) ./hack/install_prometheus.sh -CERT_MANAGER_VERSION ?= "v1.14.5" +CERT_MANAGER_VERSION ?= "v1.16.2" .PHONY: install-cert-manager install-cert-manager: cluster-ctx ## Install cert-manager helm chart. @CERT_MANAGER_VERSION=$(CERT_MANAGER_VERSION) ./hack/install_cert_manager.sh @@ -132,10 +132,10 @@ storageclass: cluster-ctx ## Create StorageClass that allows volume expansion. $(KUBECTL) apply -f ./hack/manifests/storageclass.yaml .PHONY: install -install: cluster-ctx install-crds install-config install-prometheus-crds serviceaccount storageclass cert docker-dev ## Install everything you need for local development. +install: cluster-ctx install-crds install-config install-prometheus-crds serviceaccount storageclass docker-dev ## Install everything you need for local development. .PHONY: install-ent -install-ent: cluster-ctx install-crds install-config install-prometheus-crds serviceaccount storageclass cert docker-dev-ent ## Install everything you need for local enterprise development. +install-ent: cluster-ctx install-crds install-config install-prometheus-crds serviceaccount storageclass docker-dev-ent ## Install everything you need for local enterprise development. ##@ Deploy diff --git a/make/dev.mk b/make/dev.mk index 445773a45f..36dc02bf0c 100644 --- a/make/dev.mk +++ b/make/dev.mk @@ -14,7 +14,7 @@ ENV ?= \ MARIADB_OPERATOR_NAME=$(MARIADB_OPERATOR_NAME) \ MARIADB_OPERATOR_NAMESPACE=$(MARIADB_OPERATOR_NAMESPACE) \ MARIADB_OPERATOR_SA_PATH=$(MARIADB_OPERATOR_SA_PATH) \ - MARIADB_ENTRYPOINT_VERSION=$(MARIADB_ENTRYPOINT_VERSION) \ + MARIADB_DEFAULT_VERSION=$(MARIADB_DEFAULT_VERSION) \ WATCH_NAMESPACE=$(WATCH_NAMESPACE) \ KUBEBUILDER_ASSETS=$(KUBEBUILDER_ASSETS) @@ -28,14 +28,14 @@ ENV_ENT ?= \ MARIADB_OPERATOR_NAME=$(MARIADB_OPERATOR_NAME) \ MARIADB_OPERATOR_NAMESPACE=$(MARIADB_OPERATOR_NAMESPACE) \ MARIADB_OPERATOR_SA_PATH=$(MARIADB_OPERATOR_SA_PATH) \ - MARIADB_ENTRYPOINT_VERSION=$(MARIADB_ENTRYPOINT_VERSION_ENT) \ + MARIADB_DEFAULT_VERSION=$(MARIADB_DEFAULT_VERSION_ENT) \ WATCH_NAMESPACE=$(WATCH_NAMESPACE) \ TEST_ENTERPRISE=true \ KUBEBUILDER_ASSETS=$(KUBEBUILDER_ASSETS) -TEST_ARGS ?= --coverprofile=cover.out --timeout 30m -TEST ?= $(ENV) $(GINKGO) $(TEST_ARGS) -TEST_ENT ?= $(ENV_ENT) $(GINKGO) $(TEST_ARGS) +TEST_ARGS ?= --coverprofile=cover.out +TEST ?= $(ENV) $(GINKGO) $(TEST_ARGS) --timeout 30m +TEST_ENT ?= $(ENV_ENT) $(GINKGO) $(TEST_ARGS) --timeout 40m GOCOVERDIR ?= . @@ -45,6 +45,15 @@ GOCOVERDIR ?= . test: envtest ginkgo ## Run unit tests. $(TEST) ./pkg/... ./api/... ./internal/helmtest/... +.PHONY: test-pkg +test-pkg: envtest ginkgo ## Run pkg unit tests. + $(TEST) ./pkg/... + +.PHONY: test-api +test-api: envtest ginkgo ## Run api unit tests. + $(TEST) ./api/... + +.PHONY: test-helm test-helm: envtest ginkgo ## Run helm unit tests. $(TEST) ./internal/helmtest/... @@ -82,25 +91,27 @@ run: lint ## Run a controller from your host. $(ENV) $(GO) run cmd/controller/*.go $(RUN_FLAGS) .PHONY: run-ent -run-ent: lint cert ## Run a enterprise controllers from your host. +run-ent: lint cert-webhook ## Run a enterprise controllers from your host. $(ENV_ENT) $(GO) run cmd/enterprise/*.go $(RUN_FLAGS) WEBHOOK_FLAGS ?= --log-dev --log-level=debug --log-time-encoder=iso8601 \ - --ca-cert-path=$(CA_DIR)/tls.crt --cert-dir=$(CERT_DIR) \ + --ca-cert-path=$(CA_CERT) --cert-dir=$(WEBHOOK_PKI_DIR) \ --validate-cert=false .PHONY: webhook -webhook: lint cert ## Run a webhook from your host. +webhook: lint cert-webhook ## Run a webhook from your host. $(GO) run cmd/controller/*.go webhook $(WEBHOOK_FLAGS) +# CERT_CONTROLLER_FLAGS ?= --log-dev --log-level=debug --log-time-encoder=iso8601 \ +# --ca-lifetime=26280h --cert-lifetime=2160h --renew-before-percentage=33 --requeue-duration=5m CERT_CONTROLLER_FLAGS ?= --log-dev --log-level=debug --log-time-encoder=iso8601 \ - --ca-validity=24h --cert-validity=1h --lookahead-validity=8h --requeue-duration=1m + --ca-lifetime=1h --cert-lifetime=1m --renew-before-percentage=33 --requeue-duration=30s .PHONY: cert-controller cert-controller: lint ## Run a cert-controller from your host. $(GO) run cmd/controller/*.go cert-controller $(CERT_CONTROLLER_FLAGS) BACKUP_ENV ?= AWS_ACCESS_KEY_ID=mariadb-operator AWS_SECRET_ACCESS_KEY=Minio11! BACKUP_COMMON_FLAGS ?= --path=backup --target-file-path=backup/0-backup-target.txt \ - --s3 --s3-bucket=backups --s3-endpoint=minio:9000 --s3-region=us-east-1 --s3-tls --s3-ca-cert-path=/tmp/certificate-authority/tls.crt \ + --s3 --s3-bucket=backups --s3-endpoint=minio:9000 --s3-region=us-east-1 --s3-tls --s3-ca-cert-path=/tmp/pki/ca/tls.crt \ --compression=gzip --log-dev --log-level=debug --log-time-encoder=iso8601 BACKUP_FLAGS ?= --max-retention=8h $(BACKUP_COMMON_FLAGS) diff --git a/make/helm.mk b/make/helm.mk index 2fc00dd791..c48e11ce87 100644 --- a/make/helm.mk +++ b/make/helm.mk @@ -31,7 +31,7 @@ helm-env: ## Update operator env in the Helm chart. --from-literal=RELATED_IMAGE_EXPORTER_MAXSCALE=$(RELATED_IMAGE_EXPORTER_MAXSCALE) \ --from-literal=MARIADB_OPERATOR_IMAGE=$(IMG) \ --from-literal=MARIADB_GALERA_LIB_PATH=$(MARIADB_GALERA_LIB_PATH) \ - --from-literal=MARIADB_ENTRYPOINT_VERSION=$(MARIADB_ENTRYPOINT_VERSION) \ + --from-literal=MARIADB_DEFAULT_VERSION=$(MARIADB_DEFAULT_VERSION) \ --dry-run=client -o yaml \ > $(HELM_DIR)/templates/configmap.yaml diff --git a/make/openshift.mk b/make/openshift.mk index 02726f8a92..755db0eafc 100644 --- a/make/openshift.mk +++ b/make/openshift.mk @@ -49,7 +49,7 @@ bundle: operator-sdk yq kustomize manifests ## Generate bundle manifests and met $(YQ) e -i '.spec.template.spec.containers[0].env[3].value = "$(RELATED_IMAGE_EXPORTER_MAXSCALE_ENT)"' config/manager/manager.yaml $(YQ) e -i '.spec.template.spec.containers[0].env[4].value = "$(IMG_ENT)"' config/manager/manager.yaml $(YQ) e -i '.spec.template.spec.containers[0].env[5].value = "$(MARIADB_GALERA_LIB_PATH_ENT)"' config/manager/manager.yaml - $(YQ) e -i '.spec.template.spec.containers[0].env[6].value = "$(MARIADB_ENTRYPOINT_VERSION_ENT)"' config/manager/manager.yaml + $(YQ) e -i '.spec.template.spec.containers[0].env[6].value = "$(MARIADB_DEFAULT_VERSION_ENT)"' config/manager/manager.yaml $(KUSTOMIZE) build config/manifests | $(OPERATOR_SDK) generate bundle $(BUNDLE_GEN_FLAGS) $(YQ) e -i '.metadata.annotations.containerImage = "$(IMG_ENT)"' $(BUNDLE_DIR)/manifests/mariadb-operator-enterprise.clusterserviceversion.yaml $(MAKE) bundle-validate @@ -128,7 +128,7 @@ openshift-minio: openshift-ctx cert-minio ## Deploy minio. @MINIO_VERSION=$(MINIO_VERSION) ./hack/install_minio.sh $(OC) apply -f examples/manifests/config/minio-secret.yaml -n openshift-operators $(OC) create secret generic minio-ca \ - --from-file=ca.crt=$(CA_DIR)/tls.crt \ + --from-file=tls.crt=$(CA_DIR)/tls.crt \ --dry-run=client -o yaml -n $(MINIO_CA_SECRET)| $(OC) apply -f - .PHONY: openshift-image diff --git a/make/pki.mk b/make/pki.mk index b56f417fde..2b2bab2ca6 100644 --- a/make/pki.mk +++ b/make/pki.mk @@ -1,41 +1,296 @@ ##@ PKI -CA_DIR ?= /tmp/certificate-authority -CA_CERT ?= $(CA_DIR)/tls.crt -CA_KEY ?= $(CA_DIR)/tls.key +PKI_DIR ?= /tmp/pki +CA_DIR ?= $(PKI_DIR)/ca +EC_PARAM ?= prime256v1 +EC_HASH ?= -sha256 + +# CAs ===================================================================================================================================== + .PHONY: ca -ca: ## Generates CA private key and certificate for local development. +ca: ca-server ca-client ca-mxs-admin ca-minio ## Generates CA keypairs. + +CA_SECRET_NAME ?= +CA_SECRET_NAMESPACE ?= +CA_CERT ?= +CA_KEY ?= +CA_SUBJECT ?= +.PHONY: ca-root +ca-root: ## Generates a self-signed root CA keypair with EC private key. @if [ ! -f "$(CA_CERT)" ] || [ ! -f "$(CA_KEY)" ]; then \ mkdir -p $(CA_DIR); \ - openssl req -new -newkey rsa:4096 -x509 -sha256 -days 365 -nodes \ - -subj "/CN=mariadb-operator" -out $(CA_CERT) -keyout $(CA_KEY); \ + openssl ecparam -genkey -name $(EC_PARAM) -noout -out $(CA_KEY); \ + openssl req -new -key $(CA_KEY) -x509 $(EC_HASH) -days 365 \ + -out $(CA_CERT) -subj $(CA_SUBJECT); \ else \ echo "CA files already exist, skipping generation."; \ fi -CERT_DIR ?= /tmp/k8s-webhook-server/serving-certs -CERT_SUBJECT ?= "/CN=localhost" -CERT_ALT_NAMES ?= "subjectAltName=DNS:localhost,IP:127.0.0.1" -.PHONY: cert -cert: ca ## Generates webhook private key and certificate for local development. - @mkdir -p $(CERT_DIR) - @openssl req -new -newkey rsa:4096 -x509 -sha256 -days 365 -nodes \ - -subj $(CERT_SUBJECT) -addext $(CERT_ALT_NAMES) \ - -out $(CERT_DIR)/tls.crt -keyout $(CERT_DIR)/tls.key \ - -CA $(CA_CERT) -CAkey $(CA_KEY) - -MINIO_CERT_DIR ?= /tmp/minio-certs -MINIO_CERT_SUBJECT ?= "/CN=minio.minio.svc.cluster.local" -MINIO_CERT_ALT_NAMES ?= "subjectAltName=DNS:minio,DNS:minio.minio,DNS:minio.minio.svc.cluster.local" -MINIO_CERT_NAMESPACE ?= minio + CERT_SECRET_NAME=$(CA_SECRET_NAME) \ + CERT_SECRET_NAMESPACE=$(CA_SECRET_NAMESPACE) \ + CERT=$(CA_CERT) \ + KEY=$(CA_KEY) \ + $(MAKE) cert-secret-ca + +CA_SERVER_CERT ?= $(CA_DIR)/server.crt +CA_SERVER_KEY ?= $(CA_DIR)/server.key +.PHONY: ca-server +ca-server: ## Generates server CA keypair. + CA_SECRET_NAME=mariadb-server-ca \ + CA_SECRET_NAMESPACE=default \ + CA_CERT=$(CA_SERVER_CERT) \ + CA_KEY=$(CA_SERVER_KEY) \ + CA_SUBJECT="/CN=mariadb-server-ca" \ + $(MAKE) ca-root + +CA_CLIENT_CERT ?= $(CA_DIR)/client.crt +CA_CLIENT_KEY ?= $(CA_DIR)/client.key +.PHONY: ca-client +ca-client: ## Generates client CA keypair. + CA_SECRET_NAME=mariadb-client-ca \ + CA_SECRET_NAMESPACE=default \ + CA_CERT=$(CA_CLIENT_CERT) \ + CA_KEY=$(CA_CLIENT_KEY) \ + CA_SUBJECT="/CN=mariadb-client-ca" \ + $(MAKE) ca-root + +CA_MAXSCALE_ADMIN_CERT ?= $(CA_DIR)/maxscale-admin.crt +CA_MAXSCALE_ADMIN_KEY ?= $(CA_DIR)/maxscale-admin.key +.PHONY: ca-mxs-admin +ca-mxs-admin: ## Generates MaxScale admin CA keypair. + CA_SECRET_NAME=maxscale-admin-ca \ + CA_SECRET_NAMESPACE=default \ + CA_CERT=$(CA_MAXSCALE_ADMIN_CERT) \ + CA_KEY=$(CA_MAXSCALE_ADMIN_KEY) \ + CA_SUBJECT="/CN=maxscale-admin-ca" \ + $(MAKE) ca-root + +CA_MAXSCALE_LISTENER_CERT ?= $(CA_DIR)/maxscale-listener.crt +CA_MAXSCALE_LISTENER_KEY ?= $(CA_DIR)/maxscale-listener.key +.PHONY: ca-mxs-listener +ca-mxs-listener: ## Generates MaxScale listener CA keypair. + CA_SECRET_NAME=maxscale-listener-ca \ + CA_SECRET_NAMESPACE=default \ + CA_CERT=$(CA_MAXSCALE_LISTENER_CERT) \ + CA_KEY=$(CA_MAXSCALE_LISTENER_KEY) \ + CA_SUBJECT="/CN=maxscale-listener-ca" \ + $(MAKE) ca-root + +CA_MINIO_CERT ?= $(CA_DIR)/minio.crt +CA_MINIO_KEY ?= $(CA_DIR)/minio.key +.PHONY: ca-minio +ca-minio: ## Generates minio CA keypair. + CA_SECRET_NAME=minio-ca \ + CA_SECRET_NAMESPACE=default \ + CA_CERT=$(CA_MINIO_CERT) \ + CA_KEY=$(CA_MINIO_KEY) \ + CA_SUBJECT="/CN=minio-ca" \ + $(MAKE) ca-root + +CERT_SECRET_NAME ?= +CERT_SECRET_NAMESPACE ?= +CA_CERT ?= +CA_KEY ?= +CERT ?= +KEY ?= +CERT_SUBJECT ?= +CERT_ALT_NAMES ?= + +# Leaf certs ============================================================================================================================== + +.PHONY: cert-leaf +cert-leaf: ## Generates leaf certificate keypair. + @mkdir -p $(PKI_DIR) + @openssl ecparam -genkey -name $(EC_PARAM) -noout -out $(KEY) + @openssl req -new -key $(KEY) $(EC_HASH) -sha256 -days 365 \ + -CA $(CA_CERT) -CAkey $(CA_KEY) \ + -out $(CERT) -subj $(CERT_SUBJECT) -addext $(CERT_ALT_NAMES) + + CERT_SECRET_NAME=$(CERT_SECRET_NAME) \ + CERT_SECRET_NAMESPACE=$(CERT_SECRET_NAMESPACE) \ + CERT=$(CERT) \ + KEY=$(KEY) \ + $(MAKE) cert-secret-tls + +.PHONY: cert-leaf-client +cert-leaf-client: ## Generates leaf certificate keypair for a client. + @mkdir -p $(PKI_DIR) + @openssl ecparam -genkey -name $(EC_PARAM) -noout -out $(KEY) + @openssl req -new -key $(KEY) -x509 $(EC_HASH) -days 365 \ + -CA $(CA_CERT) -CAkey $(CA_KEY) \ + -out $(CERT) -subj $(CERT_SUBJECT) + + CERT_SECRET_NAME=$(CERT_SECRET_NAME) \ + CERT_SECRET_NAMESPACE=$(CERT_SECRET_NAMESPACE) \ + CERT=$(CERT) \ + KEY=$(KEY) \ + $(MAKE) cert-secret-tls + +# MariaDB ================================================================================================================================= + +MARIADB_NAME ?= mariadb +MARIADB_NAMESPACE ?= default + +.PHONY: cert-leaf-mariadb +cert-leaf-mariadb: cert-leaf-mariadb-server cert-leaf-mariadb-client ## Generate leaf certificates for MariaDB. + +.PHONY: cert-leaf-mariadb-server +cert-leaf-mariadb-server: ca-server ## Generate server leaf certificate for MariaDB. + CERT_SECRET_NAME=$(MARIADB_NAME)-server-tls \ + CERT_SECRET_NAMESPACE=$(MARIADB_NAMESPACE) \ + CERT=$(PKI_DIR)/$(MARIADB_NAME)-server.crt \ + KEY=$(PKI_DIR)/$(MARIADB_NAME)-server.key \ + CERT_SUBJECT="/CN=$(MARIADB_NAME).default.svc.cluster.local" \ + CERT_ALT_NAMES="$(CERT_ALT_NAMES)" \ + CA_CERT=$(CA_SERVER_CERT) \ + CA_KEY=$(CA_SERVER_KEY) \ + $(MAKE) cert-leaf + +.PHONY: cert-leaf-mariadb-client +cert-leaf-mariadb-client: ca-client ## Generate client leaf certificate for MariaDB. + CERT_SECRET_NAME=$(MARIADB_NAME)-client-tls \ + CERT_SECRET_NAMESPACE=$(MARIADB_NAMESPACE) \ + CERT=$(PKI_DIR)/$(MARIADB_NAME)-client.crt \ + KEY=$(PKI_DIR)/$(MARIADB_NAME)-client.key \ + CERT_SUBJECT="/CN=$(MARIADB_NAME)-client" \ + CA_CERT=$(CA_CLIENT_CERT) \ + CA_KEY=$(CA_CLIENT_KEY) \ + $(MAKE) cert-leaf-client + +.PHONY: cert-mariadb +cert-mariadb: ## Generate certificates for MariaDB. + MARIADB_NAME="mariadb" \ + CERT_ALT_NAMES="subjectAltName=DNS:*.mariadb-internal.default.svc.cluster.local,DNS:*.mariadb-internal,DNS:mariadb.default.svc.cluster.local,DNS:localhost" \ + $(MAKE) cert-leaf-mariadb + +.PHONY: cert-mariadb-galera +cert-mariadb-galera: ## Generate certificates for MariaDB Galera. + MARIADB_NAME="mariadb-galera" \ + CERT_ALT_NAMES="subjectAltName=DNS:*.mariadb-galera-internal.default.svc.cluster.local,DNS:*.mariadb-galera-internal,DNS:mariadb-galera-primary.default.svc.cluster.local,DNS:mariadb-galera.default.svc.cluster.local,DNS:localhost" \ + $(MAKE) cert-leaf-mariadb + +.PHONY: cert-mariadb-repl +cert-mariadb-repl: ## Generate certificates for MariaDB replication. + MARIADB_NAME="mariadb-repl" \ + CERT_ALT_NAMES="subjectAltName=DNS:*.mariadb-repl-internal.default.svc.cluster.local,DNS:*.mariadb-repl-internal,DNS:mariadb-repl-primary.default.svc.cluster.local,DNS:mariadb-repl.default.svc.cluster.local,DNS:localhost" \ + $(MAKE) cert-leaf-mariadb + +# MaxScale ================================================================================================================================ + +.PHONY: cert-mxs-galera +cert-mxs-galera: cert-mxs-galera-admin cert-mxs-galera-listener ## Generate certificates for MaxScale Galera. + +.PHONY: cert-mxs-galera-admin +cert-mxs-galera-admin: ca-mxs-admin ## Generate admin certificates for MaxScale Galera. + CERT_SECRET_NAME=maxscale-galera-admin-tls \ + CERT_SECRET_NAMESPACE=default \ + CERT=$(PKI_DIR)/maxscale-galera-admin.crt \ + KEY=$(PKI_DIR)/maxscale-galera-admin.key \ + CERT_SUBJECT="/CN=maxscale-galera.default.svc.cluster.local" \ + CERT_ALT_NAMES="subjectAltName=DNS:*.maxscale-galera-internal.default.svc.cluster.local,DNS:maxscale-galera.default.svc.cluster.local,DNS:maxscale-galera-gui.default.svc.cluster.local" \ + CA_CERT=$(CA_MAXSCALE_ADMIN_CERT) \ + CA_KEY=$(CA_MAXSCALE_ADMIN_KEY) \ + $(MAKE) cert-leaf + +.PHONY: cert-mxs-galera-listener +cert-mxs-galera-listener: ca-mxs-listener ## Generate listener certificates for MaxScale Galera. + CERT_SECRET_NAME=maxscale-galera-listener-tls \ + CERT_SECRET_NAMESPACE=default \ + CERT=$(PKI_DIR)/maxscale-galera-listener.crt \ + KEY=$(PKI_DIR)/maxscale-galera-listener.key \ + CERT_SUBJECT="/CN=maxscale-galera.default.svc.cluster.local" \ + CERT_ALT_NAMES="subjectAltName=DNS:*.maxscale-galera-internal.default.svc.cluster.local,DNS:maxscale-galera.default.svc.cluster.local,DNS:maxscale-galera-gui.default.svc.cluster.local" \ + CA_CERT=$(CA_MAXSCALE_LISTENER_CERT) \ + CA_KEY=$(CA_MAXSCALE_LISTENER_KEY) \ + $(MAKE) cert-leaf + +.PHONY: cert-mxs-repl +cert-mxs-repl: cert-mxs-repl-admin cert-mxs-repl-listener ## Generate certificates for MaxScale replication. + +.PHONY: cert-mxs-repl-admin +cert-mxs-repl-admin: ca-mxs-admin ## Generate admin certificates for MaxScale replication. + CERT_SECRET_NAME=maxscale-repl-admin-tls \ + CERT_SECRET_NAMESPACE=default \ + CERT=$(PKI_DIR)/maxscale-repl-admin.crt \ + KEY=$(PKI_DIR)/maxscale-repl-admin.key \ + CERT_SUBJECT="/CN=maxscale-repl.default.svc.cluster.local" \ + CERT_ALT_NAMES="subjectAltName=DNS:*.maxscale-repl-internal.default.svc.cluster.local,DNS:maxscale-repl.default.svc.cluster.local,DNS:maxscale-repl-gui.default.svc.cluster.local" \ + CA_CERT=$(CA_MAXSCALE_ADMIN_CERT) \ + CA_KEY=$(CA_MAXSCALE_ADMIN_KEY) \ + $(MAKE) cert-leaf + +.PHONY: cert-mxs-repl-listener +cert-mxs-repl-listener: ca-mxs-listener ## Generate listener certificates for MaxScale replication. + CERT_SECRET_NAME=maxscale-repl-listener-tls \ + CERT_SECRET_NAMESPACE=default \ + CERT=$(PKI_DIR)/maxscale-repl-listener.crt \ + KEY=$(PKI_DIR)/maxscale-repl-listener.key \ + CERT_SUBJECT="/CN=maxscale-repl.default.svc.cluster.local" \ + CERT_ALT_NAMES="subjectAltName=DNS:*.maxscale-repl-internal.default.svc.cluster.local,DNS:maxscale-repl.default.svc.cluster.local,DNS:maxscale-repl-gui.default.svc.cluster.local" \ + CA_CERT=$(CA_MAXSCALE_LISTENER_CERT) \ + CA_KEY=$(CA_MAXSCALE_LISTENER_KEY) \ + $(MAKE) cert-leaf + +# Webhook ================================================================================================================================= + +WEBHOOK_PKI_DIR ?= /tmp/k8s-webhook-server/serving-certs +.PHONY: cert-webhook +cert-webhook: ca ## Generates webhook private key and certificate for local development. + PKI_DIR=$(WEBHOOK_PKI_DIR) \ + CERT_SECRET_NAME=webhook-tls \ + CERT_SECRET_NAMESPACE=default \ + CA_CERT=$(CA_SERVER_CERT) \ + CA_KEY=$(CA_SERVER_KEY) \ + CERT=$(WEBHOOK_PKI_DIR)/tls.crt \ + KEY=$(WEBHOOK_PKI_DIR)/tls.key \ + CERT_SUBJECT="/CN=localhost" \ + CERT_ALT_NAMES="subjectAltName=DNS:localhost,IP:127.0.0.1" \ + $(MAKE) cert-leaf + +# Minio =================================================================================================================================== + +MINIO_PKI_DIR ?= /tmp/pki-minio .PHONY: cert-minio -cert-minio: ca kubectl ## Generates minio private key and certificate for local development. - CERT_DIR=$(MINIO_CERT_DIR) CERT_SUBJECT=$(MINIO_CERT_SUBJECT) CERT_ALT_NAMES=$(MINIO_CERT_ALT_NAMES) $(MAKE) cert - $(KUBECTL) create namespace $(MINIO_CERT_NAMESPACE) \ +cert-minio: ca ## Generates minio private key and certificate for local development. + PKI_DIR=$(MINIO_PKI_DIR) \ + CERT_SECRET_NAME=minio-tls \ + CERT_SECRET_NAMESPACE=minio \ + CA_CERT=$(CA_MINIO_CERT) \ + CA_KEY=$(CA_MINIO_KEY) \ + CERT=$(MINIO_PKI_DIR)/tls.crt \ + KEY=$(MINIO_PKI_DIR)/tls.key \ + CERT_SUBJECT="/CN=minio.minio.svc.cluster.local" \ + CERT_ALT_NAMES="subjectAltName=DNS:minio,DNS:minio.minio,DNS:minio.minio.svc.cluster.local" \ + $(MAKE) cert-leaf + +# Secrets ================================================================================================================================= + +CERT_SECRET_NAME ?= +CERT_SECRET_NAMESPACE ?= +CERT ?= +KEY ?= + +.PHONY: cert-secret-ca +cert-secret-ca: kubectl ## Creates a CA Secret. + $(KUBECTL) create namespace $(CERT_SECRET_NAMESPACE) \ + --dry-run=client -o yaml | $(KUBECTL) apply -f - + $(KUBECTL) create secret generic $(CERT_SECRET_NAME) -n $(CERT_SECRET_NAMESPACE) \ + --from-file=ca.crt=$(CERT) --from-file=ca.key=$(KEY) \ --dry-run=client -o yaml | $(KUBECTL) apply -f - - $(KUBECTL) create secret tls minio-tls -n $(MINIO_CERT_NAMESPACE) \ - --cert=$(MINIO_CERT_DIR)/tls.crt --key=$(MINIO_CERT_DIR)/tls.key \ + $(KUBECTL) label secret $(CERT_SECRET_NAME) -n $(CERT_SECRET_NAMESPACE) \ + k8s.mariadb.com/watch="" + +.PHONY: cert-secret-tls +cert-secret-tls: kubectl ## Creates a TLS Secret. + $(KUBECTL) create namespace $(CERT_SECRET_NAMESPACE) \ + --dry-run=client -o yaml | $(KUBECTL) apply -f - + $(KUBECTL) create secret tls $(CERT_SECRET_NAME) -n $(CERT_SECRET_NAMESPACE) \ + --cert=$(CERT) --key=$(KEY) \ --dry-run=client -o yaml | $(KUBECTL) apply -f - - $(KUBECTL) create secret generic minio-ca \ - --from-file=ca.crt=$(CA_DIR)/tls.crt \ - --dry-run=client -o yaml | $(KUBECTL) apply -f - \ No newline at end of file + $(KUBECTL) label secret $(CERT_SECRET_NAME) -n $(CERT_SECRET_NAMESPACE) \ + k8s.mariadb.com/watch="" + +# Entrypoint ============================================================================================================================== + +.PHONY: cert +cert: cert-mariadb cert-mariadb-galera cert-mariadb-repl cert-mxs-galera cert-mxs-repl cert-webhook cert-minio ## Generate certificates. \ No newline at end of file diff --git a/pkg/builder/batch_builder.go b/pkg/builder/batch_builder.go index 23fd8de7ec..c50bd91a27 100644 --- a/pkg/builder/batch_builder.go +++ b/pkg/builder/batch_builder.go @@ -71,7 +71,7 @@ func (b *Builder) BuildBackupJob(key types.NamespacedName, backup *mariadbv1alph if err != nil { return nil, fmt.Errorf("error getting volume from Backup: %v", err) } - volumes, volumeSources := jobBatchStorageVolume(volume, backup.Spec.Storage.S3) + volumes, volumeSources := jobBatchStorageVolume(volume, backup.Spec.Storage.S3, mariadb) affinity := ptr.Deref(backup.Spec.Affinity, mariadbv1alpha1.AffinityConfig{}).Affinity mariadbContainer, err := b.jobMariadbContainer( @@ -198,7 +198,7 @@ func (b *Builder) BuildRestoreJob(key types.NamespacedName, restore *mariadbv1al } volume := ptr.Deref(restore.Spec.Volume, mariadbv1alpha1.StorageVolumeSource{}) - volumes, volumeSources := jobBatchStorageVolume(volume, restore.Spec.S3) + volumes, volumeSources := jobBatchStorageVolume(volume, restore.Spec.S3, mariadb) affinity := ptr.Deref(restore.Spec.Affinity, mariadbv1alpha1.AffinityConfig{}).Affinity operatorContainer, err := b.jobMariadbOperatorContainer( @@ -551,7 +551,7 @@ func s3Opts(s3 *mariadbv1alpha1.S3) []command.BackupOpt { if s3 == nil { return nil } - tls := ptr.Deref(s3.TLS, mariadbv1alpha1.TLS{}) + tls := ptr.Deref(s3.TLS, mariadbv1alpha1.TLSS3{}) cmdOpts := []command.BackupOpt{ command.WithS3( diff --git a/pkg/builder/batch_container_builder.go b/pkg/builder/batch_container_builder.go index a360dbfa8b..aeefdc1eeb 100644 --- a/pkg/builder/batch_container_builder.go +++ b/pkg/builder/batch_container_builder.go @@ -48,7 +48,7 @@ func (b *Builder) jobMariadbContainer(cmd *cmd.Command, volumeMounts []corev1.Vo } func jobBatchStorageVolume(storageVolume mariadbv1alpha1.StorageVolumeSource, - s3 *mariadbv1alpha1.S3) ([]corev1.Volume, []corev1.VolumeMount) { + s3 *mariadbv1alpha1.S3, mariadb *mariadbv1alpha1.MariaDB) ([]corev1.Volume, []corev1.VolumeMount) { volumes := []corev1.Volume{ { @@ -76,6 +76,11 @@ func jobBatchStorageVolume(storageVolume mariadbv1alpha1.StorageVolumeSource, MountPath: batchS3PKIMountPath, }) } + if mariadb.IsTLSEnabled() { + tlsVolumes, tlsVolumeMounts := mariadbTLSVolumes(mariadb) + volumes = append(volumes, tlsVolumes...) + volumeMounts = append(volumeMounts, tlsVolumeMounts...) + } return volumes, volumeMounts } diff --git a/pkg/builder/builder_test.go b/pkg/builder/builder_test.go index 33a0c4e04a..28a9b123cc 100644 --- a/pkg/builder/builder_test.go +++ b/pkg/builder/builder_test.go @@ -4,6 +4,7 @@ import ( "reflect" "testing" + certmanagerv1 "github.com/cert-manager/cert-manager/pkg/apis/certmanager/v1" mariadbv1alpha1 "github.com/mariadb-operator/mariadb-operator/api/v1alpha1" "github.com/mariadb-operator/mariadb-operator/pkg/discovery" "github.com/mariadb-operator/mariadb-operator/pkg/environment" @@ -19,13 +20,14 @@ func newTestBuilder(discovery *discovery.Discovery) *Builder { utilruntime.Must(clientgoscheme.AddToScheme(scheme)) utilruntime.Must(mariadbv1alpha1.AddToScheme(scheme)) utilruntime.Must(monitoringv1.AddToScheme(scheme)) + utilruntime.Must(certmanagerv1.AddToScheme(scheme)) env := &environment.OperatorEnv{ MariadbOperatorName: "mariadb-operator", MariadbOperatorNamespace: "test", MariadbOperatorSAPath: "/var/run/secrets/kubernetes.io/serviceaccount/token", MariadbOperatorImage: "mariadb-operator:test", - RelatedMariadbImage: "mariadb:11.2.2:test", + RelatedMariadbImage: "mariadb:test", RelatedMaxscaleImage: "maxscale:test", RelatedExporterImage: "mysql-exporter:test", MariadbGaleraLibPath: "/usr/lib/galera/libgalera_smm.so", diff --git a/pkg/builder/certificate_builder.go b/pkg/builder/certificate_builder.go new file mode 100644 index 0000000000..76d3bbf7cb --- /dev/null +++ b/pkg/builder/certificate_builder.go @@ -0,0 +1,125 @@ +package builder + +import ( + "errors" + "fmt" + "time" + + certmanagerv1 "github.com/cert-manager/cert-manager/pkg/apis/certmanager/v1" + cmmeta "github.com/cert-manager/cert-manager/pkg/apis/meta/v1" + "github.com/mariadb-operator/mariadb-operator/pkg/metadata" + "github.com/mariadb-operator/mariadb-operator/pkg/pki" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "k8s.io/utils/ptr" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" +) + +type CertOpts struct { + Key *types.NamespacedName + Owner metav1.Object + DNSNames []string + Lifetime *time.Duration + RenewBeforePercentage *int32 + Usages []certmanagerv1.KeyUsage + IssuerRef *cmmeta.ObjectReference +} + +type CertOpt func(*CertOpts) + +func WithKey(key types.NamespacedName) CertOpt { + return func(o *CertOpts) { + o.Key = ptr.To(key) + } +} + +func WithOwner(owner metav1.Object) CertOpt { + return func(o *CertOpts) { + o.Owner = owner + } +} + +func WithDNSnames(names []string) CertOpt { + return func(o *CertOpts) { + o.DNSNames = names + } +} + +func WithLifetime(lifetime time.Duration) CertOpt { + return func(o *CertOpts) { + o.Lifetime = ptr.To(lifetime) + } +} + +func WithRenewBeforePercentage(percentage int32) CertOpt { + return func(o *CertOpts) { + o.RenewBeforePercentage = ptr.To(percentage) + } +} + +func WithUsages(usages ...certmanagerv1.KeyUsage) CertOpt { + return func(o *CertOpts) { + o.Usages = append(o.Usages, usages...) + } +} + +func WithIssuerRef(issuerRef cmmeta.ObjectReference) CertOpt { + return func(o *CertOpts) { + o.IssuerRef = ptr.To(issuerRef) + } +} + +func (b *Builder) BuildCertificate(certOpts ...CertOpt) (*certmanagerv1.Certificate, error) { + opts := CertOpts{ + Lifetime: ptr.To(pki.DefaultCertLifetime), + RenewBeforePercentage: ptr.To(pki.DefaultRenewBeforePercentage), + Usages: []certmanagerv1.KeyUsage{ + certmanagerv1.UsageDigitalSignature, + certmanagerv1.UsageKeyAgreement, + }, + } + for _, setOpt := range certOpts { + setOpt(&opts) + } + if opts.Key == nil || opts.Owner == nil || len(opts.DNSNames) == 0 || + opts.Lifetime == nil || opts.RenewBeforePercentage == nil || opts.IssuerRef == nil { + return nil, errors.New("Key, Owner, DNSNames, Lifetime, RenewBeforePercentage and IssuerRef must be set") + } + + renewBefore, err := pki.RenewalDuration(*opts.Lifetime, *opts.RenewBeforePercentage) + if err != nil { + return nil, fmt.Errorf("error getting renewal duration: %v", err) + } + + cert := &certmanagerv1.Certificate{ + ObjectMeta: metav1.ObjectMeta{ + Name: opts.Key.Name, + Namespace: opts.Key.Namespace, + }, + Spec: certmanagerv1.CertificateSpec{ + Duration: &metav1.Duration{Duration: *opts.Lifetime}, + RenewBefore: &metav1.Duration{Duration: *renewBefore}, + DNSNames: opts.DNSNames, + CommonName: opts.DNSNames[0], + Usages: opts.Usages, + IssuerRef: *opts.IssuerRef, + IsCA: false, + PrivateKey: &certmanagerv1.CertificatePrivateKey{ + Encoding: certmanagerv1.PKCS1, + Algorithm: certmanagerv1.ECDSAKeyAlgorithm, + Size: 256, + }, + SecretTemplate: &certmanagerv1.CertificateSecretTemplate{ + Labels: map[string]string{ + metadata.WatchLabel: "", + }, + }, + SecretName: opts.Key.Name, + RevisionHistoryLimit: ptr.To(int32(10)), + }, + } + if err := controllerutil.SetControllerReference(opts.Owner, cert, b.scheme); err != nil { + return nil, fmt.Errorf("error setting controller reference to Certificate: %v", err) + } + return cert, nil +} diff --git a/pkg/builder/certificate_builder_test.go b/pkg/builder/certificate_builder_test.go new file mode 100644 index 0000000000..dc2d7c968c --- /dev/null +++ b/pkg/builder/certificate_builder_test.go @@ -0,0 +1,112 @@ +package builder + +import ( + "testing" + "time" + + cmmeta "github.com/cert-manager/cert-manager/pkg/apis/meta/v1" + mariadbv1alpha1 "github.com/mariadb-operator/mariadb-operator/api/v1alpha1" + "k8s.io/apimachinery/pkg/types" +) + +func TestBuildCertificate(t *testing.T) { + builder := newDefaultTestBuilder(t) + key := types.NamespacedName{ + Name: "test-cert", + Namespace: "test", + } + owner := &mariadbv1alpha1.MariaDB{} + tests := []struct { + name string + certOpts []CertOpt + wantErr bool + }{ + { + name: "missing key", + certOpts: []CertOpt{ + WithOwner(owner), + WithDNSnames([]string{"example.com"}), + WithLifetime(24 * time.Hour), + WithRenewBeforePercentage(50), + WithIssuerRef(cmmeta.ObjectReference{Name: "test-issuer"}), + }, + wantErr: true, + }, + { + name: "missing owner", + certOpts: []CertOpt{ + WithKey(key), + WithDNSnames([]string{"example.com"}), + WithLifetime(24 * time.Hour), + WithRenewBeforePercentage(50), + WithIssuerRef(cmmeta.ObjectReference{Name: "test-issuer"}), + }, + wantErr: true, + }, + { + name: "missing DNS names", + certOpts: []CertOpt{ + WithKey(key), + WithOwner(owner), + WithLifetime(24 * time.Hour), + WithRenewBeforePercentage(50), + WithIssuerRef(cmmeta.ObjectReference{Name: "test-issuer"}), + }, + wantErr: true, + }, + { + name: "missing lifetime", + certOpts: []CertOpt{ + WithKey(key), + WithOwner(owner), + WithDNSnames([]string{"example.com"}), + WithRenewBeforePercentage(50), + WithIssuerRef(cmmeta.ObjectReference{Name: "test-issuer"}), + }, + wantErr: false, + }, + { + name: "missing renew before percentage", + certOpts: []CertOpt{ + WithKey(key), + WithOwner(owner), + WithDNSnames([]string{"example.com"}), + WithLifetime(24 * time.Hour), + WithIssuerRef(cmmeta.ObjectReference{Name: "test-issuer"}), + }, + wantErr: false, + }, + { + name: "missing issuer ref", + certOpts: []CertOpt{ + WithKey(key), + WithOwner(owner), + WithDNSnames([]string{"example.com"}), + WithLifetime(24 * time.Hour), + WithRenewBeforePercentage(50), + }, + wantErr: true, + }, + { + name: "valid options", + certOpts: []CertOpt{ + WithKey(key), + WithOwner(owner), + WithDNSnames([]string{"example.com"}), + WithLifetime(24 * time.Hour), + WithRenewBeforePercentage(50), + WithIssuerRef(cmmeta.ObjectReference{Name: "test-issuer"}), + }, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := builder.BuildCertificate(tt.certOpts...) + if (err != nil) != tt.wantErr { + t.Errorf("BuildCertificate() error = %v, expectError %v", err, tt.wantErr) + } + }) + } +} diff --git a/pkg/builder/container_builder.go b/pkg/builder/container_builder.go index e3ce29d73a..4dd5de4277 100644 --- a/pkg/builder/container_builder.go +++ b/pkg/builder/container_builder.go @@ -9,6 +9,7 @@ import ( "strconv" mariadbv1alpha1 "github.com/mariadb-operator/mariadb-operator/api/v1alpha1" + builderpki "github.com/mariadb-operator/mariadb-operator/pkg/builder/pki" "github.com/mariadb-operator/mariadb-operator/pkg/command" galeraresources "github.com/mariadb-operator/mariadb-operator/pkg/controller/galera/resources" kadapter "github.com/mariadb-operator/mariadb-operator/pkg/kubernetes/adapter" @@ -46,7 +47,7 @@ var ( ProbeHandler: corev1.ProbeHandler{ HTTPGet: &corev1.HTTPGetAction{ Path: "/health", - Port: intstr.FromInt(int(galera.Agent.Port)), + Port: intstr.FromInt(int(galera.Agent.ProbePort)), }, }, } @@ -184,12 +185,17 @@ func (b *Builder) galeraAgentContainer(mariadb *mariadbv1alpha1.MariaDB) (*corev Name: galeraresources.AgentPortName, ContainerPort: agent.Port, }, + { + Name: galeraresources.AgentProbePortName, + ContainerPort: agent.ProbePort, + }, } container.Args = func() []string { - args := container.Args + var args []string args = append(args, []string{ "agent", fmt.Sprintf("--addr=:%d", agent.Port), + fmt.Sprintf("--probe-addr=:%d", agent.ProbePort), fmt.Sprintf("--config-dir=%s", galeraresources.GaleraConfigMountPath), fmt.Sprintf("--state-dir=%s", MariadbStorageMountPath), }...) @@ -214,6 +220,7 @@ func (b *Builder) galeraAgentContainer(mariadb *mariadbv1alpha1.MariaDB) (*corev }...) } + args = append(args, container.Args...) return args }() container.Env = mariadbEnv(mariadb) @@ -397,6 +404,49 @@ func mariadbEnv(mariadb *mariadbv1alpha1.MariaDB) []corev1.EnvVar { }, } + if mariadb.IsTLSEnabled() { + env = append(env, []corev1.EnvVar{ + { + Name: "TLS_ENABLED", + Value: strconv.FormatBool(mariadb.IsTLSEnabled()), + }, + { + Name: "TLS_CA_CERT_PATH", + Value: builderpki.CACertPath, + }, + { + Name: "TLS_SERVER_CERT_PATH", + Value: builderpki.ServerCertPath, + }, + { + Name: "TLS_SERVER_KEY_PATH", + Value: builderpki.ServerKeyPath, + }, + { + Name: "TLS_CLIENT_CERT_PATH", + Value: builderpki.ClientCertPath, + }, + { + Name: "TLS_CLIENT_KEY_PATH", + Value: builderpki.ClientKeyPath, + }, + }...) + + // By default, wsrep_sst_mariabackup.sh validates the client certificate commonName against the Pod IP. + // This doesn't work with Kubernetes, we cannot issue a certificate for a specific IP, as Pod IPs are ephemeral and unpredictable. + // Instead, we could configure wsrep_sst_mariabackup.sh to validate the certificate against the expected commonName: + // See: + // https://github.com/codership/mariadb-server/blob/16394f1aa1b4097f897b8ab01ea2064726cca059/scripts/wsrep_sst_common.sh#L1064 + // https://github.com/codership/mariadb-server/blob/16394f1aa1b4097f897b8ab01ea2064726cca059/scripts/wsrep_sst_mariabackup.sh#L407 + clientNames := mariadb.TLSClientNames() + if mariadb.IsGaleraEnabled() && len(clientNames) > 0 { + env = append(env, corev1.EnvVar{ + Name: "WSREP_SST_OPT_REMOTE_AUTH", + Value: fmt.Sprintf("%s:", clientNames[0]), + }) + } + } + if mariadb.IsRootPasswordEmpty() { env = append(env, corev1.EnvVar{ Name: "MARIADB_ALLOW_EMPTY_ROOT_PASSWORD", @@ -443,6 +493,12 @@ func mariadbVolumeMounts(mariadb *mariadbv1alpha1.MariaDB, opts ...mariadbPodOpt MountPath: MariadbConfigMountPath, }, } + + if mariadb.IsTLSEnabled() { + _, tlsVolumeMounts := mariadbTLSVolumes(mariadb) + volumeMounts = append(volumeMounts, tlsVolumeMounts...) + } + galera := ptr.Deref(mariadb.Spec.Galera, mariadbv1alpha1.Galera{}) reuseStorageVolume := ptr.Deref(galera.Config.ReuseStorageVolume, false) @@ -520,6 +576,10 @@ func maxscaleVolumeMounts(maxscale *mariadbv1alpha1.MaxScale) []corev1.VolumeMou MountPath: MaxScaleCacheMountPath, }, } + if maxscale.IsTLSEnabled() { + _, tlsVolumeMounts := maxscaleTLSVolumes(maxscale) + volumeMounts = append(volumeMounts, tlsVolumeMounts...) + } if maxscale.Spec.VolumeMounts != nil { volumeMounts = append(volumeMounts, kadapter.ToKubernetesSlice(maxscale.Spec.VolumeMounts)...) } @@ -612,7 +672,7 @@ func mariadbGaleraProbe(mdb *mariadbv1alpha1.MariaDB, path string, probe *mariad ProbeHandler: corev1.ProbeHandler{ HTTPGet: &corev1.HTTPGetAction{ Path: path, - Port: intstr.FromInt(int(agent.Port)), + Port: intstr.FromInt(int(agent.ProbePort)), }, }, InitialDelaySeconds: 20, @@ -631,8 +691,7 @@ func maxscaleProbe(mxs *mariadbv1alpha1.MaxScale, probe *mariadbv1alpha1.Probe) } mxsProbe := corev1.Probe{ ProbeHandler: corev1.ProbeHandler{ - HTTPGet: &corev1.HTTPGetAction{ - Path: "/", + TCPSocket: &corev1.TCPSocketAction{ Port: intstr.FromInt(int(mxs.Spec.Admin.Port)), }, }, diff --git a/pkg/builder/container_builder_test.go b/pkg/builder/container_builder_test.go index d9f72c9a22..857b95cac6 100644 --- a/pkg/builder/container_builder_test.go +++ b/pkg/builder/container_builder_test.go @@ -3,10 +3,13 @@ package builder import ( "fmt" "reflect" + "sort" "strconv" "testing" + "github.com/google/go-cmp/cmp" mariadbv1alpha1 "github.com/mariadb-operator/mariadb-operator/api/v1alpha1" + builderpki "github.com/mariadb-operator/mariadb-operator/pkg/builder/pki" "github.com/mariadb-operator/mariadb-operator/pkg/command" "github.com/mariadb-operator/mariadb-operator/pkg/datastructures" "github.com/mariadb-operator/mariadb-operator/pkg/discovery" @@ -244,7 +247,7 @@ func TestMariadbLivenessProbe(t *testing.T) { Enabled: true, GaleraSpec: mariadbv1alpha1.GaleraSpec{ Agent: mariadbv1alpha1.GaleraAgent{ - Port: 5555, + ProbePort: 5566, }, }, }, @@ -254,7 +257,7 @@ func TestMariadbLivenessProbe(t *testing.T) { ProbeHandler: corev1.ProbeHandler{ HTTPGet: &corev1.HTTPGetAction{ Path: "/liveness", - Port: intstr.FromInt(5555), + Port: intstr.FromInt(5566), }, }, InitialDelaySeconds: 20, @@ -270,7 +273,7 @@ func TestMariadbLivenessProbe(t *testing.T) { Enabled: true, GaleraSpec: mariadbv1alpha1.GaleraSpec{ Agent: mariadbv1alpha1.GaleraAgent{ - Port: 5555, + ProbePort: 5566, }, }, }, @@ -287,7 +290,7 @@ func TestMariadbLivenessProbe(t *testing.T) { ProbeHandler: corev1.ProbeHandler{ HTTPGet: &corev1.HTTPGetAction{ Path: "/liveness", - Port: intstr.FromInt(5555), + Port: intstr.FromInt(5566), }, }, InitialDelaySeconds: 10, @@ -303,7 +306,7 @@ func TestMariadbLivenessProbe(t *testing.T) { Enabled: true, GaleraSpec: mariadbv1alpha1.GaleraSpec{ Agent: mariadbv1alpha1.GaleraAgent{ - Port: 5555, + ProbePort: 5566, }, }, }, @@ -312,7 +315,7 @@ func TestMariadbLivenessProbe(t *testing.T) { ProbeHandler: mariadbv1alpha1.ProbeHandler{ HTTPGet: &mariadbv1alpha1.HTTPGetAction{ Path: "/liveness-custom", - Port: intstr.FromInt(5555), + Port: intstr.FromInt(5566), }, }, InitialDelaySeconds: 10, @@ -326,7 +329,7 @@ func TestMariadbLivenessProbe(t *testing.T) { ProbeHandler: corev1.ProbeHandler{ HTTPGet: &corev1.HTTPGetAction{ Path: "/liveness", - Port: intstr.FromInt(5555), + Port: intstr.FromInt(5566), }, }, InitialDelaySeconds: 10, @@ -574,7 +577,7 @@ func TestMariadbReadinessProbe(t *testing.T) { Enabled: true, GaleraSpec: mariadbv1alpha1.GaleraSpec{ Agent: mariadbv1alpha1.GaleraAgent{ - Port: 5555, + ProbePort: 5566, }, }, }, @@ -584,7 +587,7 @@ func TestMariadbReadinessProbe(t *testing.T) { ProbeHandler: corev1.ProbeHandler{ HTTPGet: &corev1.HTTPGetAction{ Path: "/readiness", - Port: intstr.FromInt(5555), + Port: intstr.FromInt(5566), }, }, InitialDelaySeconds: 20, @@ -600,7 +603,7 @@ func TestMariadbReadinessProbe(t *testing.T) { Enabled: true, GaleraSpec: mariadbv1alpha1.GaleraSpec{ Agent: mariadbv1alpha1.GaleraAgent{ - Port: 5555, + ProbePort: 5566, }, }, }, @@ -617,7 +620,7 @@ func TestMariadbReadinessProbe(t *testing.T) { ProbeHandler: corev1.ProbeHandler{ HTTPGet: &corev1.HTTPGetAction{ Path: "/readiness", - Port: intstr.FromInt(5555), + Port: intstr.FromInt(5566), }, }, InitialDelaySeconds: 10, @@ -633,7 +636,7 @@ func TestMariadbReadinessProbe(t *testing.T) { Enabled: true, GaleraSpec: mariadbv1alpha1.GaleraSpec{ Agent: mariadbv1alpha1.GaleraAgent{ - Port: 5555, + ProbePort: 5566, }, }, }, @@ -642,7 +645,7 @@ func TestMariadbReadinessProbe(t *testing.T) { ProbeHandler: mariadbv1alpha1.ProbeHandler{ HTTPGet: &mariadbv1alpha1.HTTPGetAction{ Path: "/readiness-custom", - Port: intstr.FromInt(5555), + Port: intstr.FromInt(5566), }, }, InitialDelaySeconds: 10, @@ -656,7 +659,7 @@ func TestMariadbReadinessProbe(t *testing.T) { ProbeHandler: corev1.ProbeHandler{ HTTPGet: &corev1.HTTPGetAction{ Path: "/readiness", - Port: intstr.FromInt(5555), + Port: intstr.FromInt(5566), }, }, InitialDelaySeconds: 10, @@ -695,8 +698,7 @@ func TestMaxScaleProbe(t *testing.T) { probe: &mariadbv1alpha1.Probe{}, wantProbe: &corev1.Probe{ ProbeHandler: corev1.ProbeHandler{ - HTTPGet: &corev1.HTTPGetAction{ - Path: "/", + TCPSocket: &corev1.TCPSocketAction{ Port: intstr.FromInt(8989), }, }, @@ -721,8 +723,7 @@ func TestMaxScaleProbe(t *testing.T) { }, wantProbe: &corev1.Probe{ ProbeHandler: corev1.ProbeHandler{ - HTTPGet: &corev1.HTTPGetAction{ - Path: "/", + TCPSocket: &corev1.TCPSocketAction{ Port: intstr.FromInt(8989), }, }, @@ -742,8 +743,8 @@ func TestMaxScaleProbe(t *testing.T) { }, probe: &mariadbv1alpha1.Probe{ ProbeHandler: mariadbv1alpha1.ProbeHandler{ - HTTPGet: &mariadbv1alpha1.HTTPGetAction{ - Path: "/custom", + TCPSocket: &mariadbv1alpha1.TCPSocketAction{ + Host: "custom", Port: intstr.FromInt(8989), }, }, @@ -753,8 +754,8 @@ func TestMaxScaleProbe(t *testing.T) { }, wantProbe: &corev1.Probe{ ProbeHandler: corev1.ProbeHandler{ - HTTPGet: &corev1.HTTPGetAction{ - Path: "/custom", + TCPSocket: &corev1.TCPSocketAction{ + Host: "custom", Port: intstr.FromInt(8989), }, }, @@ -957,6 +958,96 @@ func TestMariadbEnv(t *testing.T) { }, wantEnv: removeEnv(defaultEnv(nil), "MYSQL_INITDB_SKIP_TZINFO"), }, + { + name: "MariaDB TLS", + mariadb: &mariadbv1alpha1.MariaDB{ + Spec: mariadbv1alpha1.MariaDBSpec{ + TLS: &mariadbv1alpha1.TLS{ + Enabled: true, + }, + }, + }, + wantEnv: append(defaultEnv(nil), + []corev1.EnvVar{ + { + Name: "TLS_ENABLED", + Value: strconv.FormatBool(true), + }, + { + Name: "TLS_CA_CERT_PATH", + Value: builderpki.CACertPath, + }, + { + Name: "TLS_SERVER_CERT_PATH", + Value: builderpki.ServerCertPath, + }, + { + Name: "TLS_SERVER_KEY_PATH", + Value: builderpki.ServerKeyPath, + }, + { + Name: "TLS_CLIENT_CERT_PATH", + Value: builderpki.ClientCertPath, + }, + { + Name: "TLS_CLIENT_KEY_PATH", + Value: builderpki.ClientKeyPath, + }, + }...), + }, + { + name: "MariaDB Galera TLS", + mariadb: &mariadbv1alpha1.MariaDB{ + ObjectMeta: metav1.ObjectMeta{ + Name: "mariadb-galera", + }, + Spec: mariadbv1alpha1.MariaDBSpec{ + Galera: &mariadbv1alpha1.Galera{ + Enabled: true, + }, + TLS: &mariadbv1alpha1.TLS{ + Enabled: true, + }, + }, + }, + wantEnv: append( + defaultEnv([]corev1.EnvVar{ + { + Name: "MARIADB_NAME", + Value: "mariadb-galera", + }, + }), + []corev1.EnvVar{ + { + Name: "TLS_ENABLED", + Value: strconv.FormatBool(true), + }, + { + Name: "TLS_CA_CERT_PATH", + Value: builderpki.CACertPath, + }, + { + Name: "TLS_SERVER_CERT_PATH", + Value: builderpki.ServerCertPath, + }, + { + Name: "TLS_SERVER_KEY_PATH", + Value: builderpki.ServerKeyPath, + }, + { + Name: "TLS_CLIENT_CERT_PATH", + Value: builderpki.ClientCertPath, + }, + { + Name: "TLS_CLIENT_KEY_PATH", + Value: builderpki.ClientKeyPath, + }, + { + Name: "WSREP_SST_OPT_REMOTE_AUTH", + Value: "mariadb-galera-client:", + }, + }...), + }, { name: "MariaDB env append", mariadb: &mariadbv1alpha1.MariaDB{ @@ -1069,8 +1160,11 @@ func TestMariadbEnv(t *testing.T) { t.Setenv("CLUSTER_NAME", "example.com") } env := mariadbEnv(tt.mariadb) - if !reflect.DeepEqual(tt.wantEnv, env) { - t.Errorf("unexpected result:\nexpected:\n%s\ngot:\n%s\n", tt.wantEnv, env) + sortedWantEnv := sortEnvVars(tt.wantEnv) + sortedEnv := sortEnvVars(env) + + if diff := cmp.Diff(sortedWantEnv, sortedEnv); diff != "" { + t.Errorf("unexpected env (-want +got):\n%s", diff) } }) } @@ -1548,3 +1642,12 @@ func removeEnv(env []corev1.EnvVar, key string) []corev1.EnvVar { } return result } + +func sortEnvVars(env []corev1.EnvVar) []corev1.EnvVar { + sortedEnv := make([]corev1.EnvVar, len(env)) + copy(sortedEnv, env) + sort.SliceStable(sortedEnv, func(i, j int) bool { + return sortedEnv[i].Name < sortedEnv[j].Name + }) + return sortedEnv +} diff --git a/pkg/builder/deployment_builder.go b/pkg/builder/deployment_builder.go index 0366de7e2a..7130ca398d 100644 --- a/pkg/builder/deployment_builder.go +++ b/pkg/builder/deployment_builder.go @@ -49,6 +49,8 @@ func (b *Builder) BuildExporterDeployment(mariadb *mariadbv1alpha1.MariaDB, WithLabels(selectorLabels). Build() + volumes, volumeMounts := b.mariadbExporterVolumes(mariadb) + podTemplate, err := b.exporterPodTemplate( podObjMeta, &exporter, @@ -56,7 +58,8 @@ func (b *Builder) BuildExporterDeployment(mariadb *mariadbv1alpha1.MariaDB, fmt.Sprintf("--config.my-cnf=%s", exporterConfigFile(config.Key)), }, mariadb.Spec.ImagePullSecrets, - config.Name, + withExporterVolumes(volumes), + withExporterVolumeMounts(volumeMounts), ) if err != nil { return nil, fmt.Errorf("error building exporter pod template: %v", err) @@ -101,6 +104,8 @@ func (b *Builder) BuildMaxScaleExporterDeployment(mxs *mariadbv1alpha1.MaxScale, WithLabels(selectorLabels). Build() + volumes, volumeMounts := b.maxscaleExporterVolumes(mxs) + podTemplate, err := b.exporterPodTemplate( podObjMeta, &exporter, @@ -108,7 +113,8 @@ func (b *Builder) BuildMaxScaleExporterDeployment(mxs *mariadbv1alpha1.MaxScale, fmt.Sprintf("--config=%s", exporterConfigFile(config.Key)), }, mxs.Spec.ImagePullSecrets, - config.Name, + withExporterVolumes(volumes), + withExporterVolumeMounts(volumeMounts), ) if err != nil { return nil, fmt.Errorf("error building MaxScale exporter pod template: %v", err) @@ -129,14 +135,90 @@ func (b *Builder) BuildMaxScaleExporterDeployment(mxs *mariadbv1alpha1.MaxScale, return deployment, nil } +func (b *Builder) mariadbExporterVolumes(mariadb *mariadbv1alpha1.MariaDB) ([]corev1.Volume, []corev1.VolumeMount) { + volumes := []corev1.Volume{ + { + Name: deployConfigVolume, + VolumeSource: corev1.VolumeSource{ + Secret: &corev1.SecretVolumeSource{ + SecretName: mariadb.MetricsConfigSecretKeyRef().Name, + }, + }, + }, + } + volumeMounts := []corev1.VolumeMount{ + { + Name: deployConfigVolume, + MountPath: deployConfigMountPath, + ReadOnly: true, + }, + } + if mariadb.IsTLSEnabled() { + tlsVolumes, tlsVolumeMounts := mariadbTLSVolumes(mariadb) + volumes = append(volumes, tlsVolumes...) + volumeMounts = append(volumeMounts, tlsVolumeMounts...) + } + return volumes, volumeMounts +} + +func (b *Builder) maxscaleExporterVolumes(mxs *mariadbv1alpha1.MaxScale) ([]corev1.Volume, []corev1.VolumeMount) { + volumes := []corev1.Volume{ + { + Name: deployConfigVolume, + VolumeSource: corev1.VolumeSource{ + Secret: &corev1.SecretVolumeSource{ + SecretName: mxs.MetricsConfigSecretKeyRef().Name, + }, + }, + }, + } + volumeMounts := []corev1.VolumeMount{ + { + Name: deployConfigVolume, + MountPath: deployConfigMountPath, + ReadOnly: true, + }, + } + if mxs.IsTLSEnabled() { + tlsVolumes, tlsVolumeMounts := maxscaleTLSVolumes(mxs) + volumes = append(volumes, tlsVolumes...) + volumeMounts = append(volumeMounts, tlsVolumeMounts...) + } + return volumes, volumeMounts +} + +type exporterOptions struct { + volumes []corev1.Volume + volumeMounts []corev1.VolumeMount +} + +type exporterOption func(*exporterOptions) + +func withExporterVolumes(volumes []corev1.Volume) exporterOption { + return func(eo *exporterOptions) { + eo.volumes = volumes + } +} + +func withExporterVolumeMounts(volumeMounts []corev1.VolumeMount) exporterOption { + return func(eo *exporterOptions) { + eo.volumeMounts = volumeMounts + } +} + func (b *Builder) exporterPodTemplate(objMeta metav1.ObjectMeta, exporter *mariadbv1alpha1.Exporter, args []string, - pullSecrets []mariadbv1alpha1.LocalObjectReference, configSecretName string) (*corev1.PodTemplateSpec, error) { + pullSecrets []mariadbv1alpha1.LocalObjectReference, exporterOpts ...exporterOption) (*corev1.PodTemplateSpec, error) { + opts := exporterOptions{} + for _, setOpt := range exporterOpts { + setOpt(&opts) + } + securityContext, err := b.buildPodSecurityContext(exporter.PodSecurityContext) if err != nil { return nil, err } - container, err := b.exporterContainer(exporter, args) + container, err := b.exporterContainer(exporter, args, withExporterVolumeMounts(opts.volumeMounts)) if err != nil { return nil, fmt.Errorf("error building exporter container: %v", err) } @@ -150,16 +232,7 @@ func (b *Builder) exporterPodTemplate(objMeta metav1.ObjectMeta, exporter *maria Containers: []corev1.Container{ *container, }, - Volumes: []corev1.Volume{ - { - Name: deployConfigVolume, - VolumeSource: corev1.VolumeSource{ - Secret: &corev1.SecretVolumeSource{ - SecretName: configSecretName, - }, - }, - }, - }, + Volumes: opts.volumes, SecurityContext: securityContext, Affinity: ptr.To(affinity.ToKubernetesType()), NodeSelector: exporter.NodeSelector, @@ -169,7 +242,13 @@ func (b *Builder) exporterPodTemplate(objMeta metav1.ObjectMeta, exporter *maria }, nil } -func (b *Builder) exporterContainer(exporter *mariadbv1alpha1.Exporter, args []string) (*corev1.Container, error) { +func (b *Builder) exporterContainer(exporter *mariadbv1alpha1.Exporter, args []string, + exporterOpts ...exporterOption) (*corev1.Container, error) { + opts := exporterOptions{} + for _, setOpt := range exporterOpts { + setOpt(&opts) + } + securityContext, err := b.buildContainerSecurityContext(exporter.SecurityContext) if err != nil { return nil, fmt.Errorf("error building container security context: %v", err) @@ -200,13 +279,7 @@ func (b *Builder) exporterContainer(exporter *mariadbv1alpha1.Exporter, args []s ContainerPort: exporter.Port, }, }, - VolumeMounts: []corev1.VolumeMount{ - { - Name: deployConfigVolume, - MountPath: deployConfigMountPath, - ReadOnly: true, - }, - }, + VolumeMounts: opts.volumeMounts, Resources: resources, SecurityContext: securityContext, LivenessProbe: probe, diff --git a/pkg/builder/deployment_builder_test.go b/pkg/builder/deployment_builder_test.go index e7d9310ee3..461971e652 100644 --- a/pkg/builder/deployment_builder_test.go +++ b/pkg/builder/deployment_builder_test.go @@ -5,6 +5,8 @@ import ( "testing" mariadbv1alpha1 "github.com/mariadb-operator/mariadb-operator/api/v1alpha1" + builderpki "github.com/mariadb-operator/mariadb-operator/pkg/builder/pki" + "github.com/mariadb-operator/mariadb-operator/pkg/datastructures" "github.com/mariadb-operator/mariadb-operator/pkg/metadata" corev1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/api/resource" @@ -790,3 +792,69 @@ func TestExporterMaxScaleDeploymentMeta(t *testing.T) { }) } } + +func TestExporterVolumes(t *testing.T) { + builder := newDefaultTestBuilder(t) + tests := []struct { + name string + mariadb *mariadbv1alpha1.MariaDB + wantVolumeNames []string + }{ + { + name: "empty", + mariadb: &mariadbv1alpha1.MariaDB{ + Spec: mariadbv1alpha1.MariaDBSpec{ + Metrics: &mariadbv1alpha1.MariadbMetrics{ + Enabled: true, + }, + }, + }, + wantVolumeNames: []string{ + deployConfigVolume, + }, + }, + { + name: "TLS", + mariadb: &mariadbv1alpha1.MariaDB{ + Spec: mariadbv1alpha1.MariaDBSpec{ + Metrics: &mariadbv1alpha1.MariadbMetrics{ + Enabled: true, + }, + TLS: &mariadbv1alpha1.TLS{ + Enabled: true, + }, + }, + }, + wantVolumeNames: []string{ + deployConfigVolume, + builderpki.PKIVolume, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + deploy, err := builder.BuildExporterDeployment(tt.mariadb, nil) + if err != nil { + t.Fatalf("unexpected error building Deployment: %v", err) + } + + volumes := deploy.Spec.Template.Spec.Volumes + volumeMounts := deploy.Spec.Template.Spec.Containers[0].VolumeMounts + + volumeIndex := datastructures.NewIndex(volumes, func(v corev1.Volume) string { + return v.Name + }) + volumeMountIndex := datastructures.NewIndex(volumeMounts, func(vm corev1.VolumeMount) string { + return vm.Name + }) + + if !datastructures.AllExists(volumeIndex, tt.wantVolumeNames...) { + t.Errorf("expecting all volumes %v to exist", tt.wantVolumeNames) + } + if !datastructures.AllExists(volumeMountIndex, tt.wantVolumeNames...) { + t.Errorf("expecting all volumeMounts %v to exist", tt.wantVolumeNames) + } + }) + } +} diff --git a/pkg/builder/maxscale_builder.go b/pkg/builder/maxscale_builder.go index 6bb00edecf..fab1fc5d2d 100644 --- a/pkg/builder/maxscale_builder.go +++ b/pkg/builder/maxscale_builder.go @@ -34,6 +34,7 @@ func (b *Builder) BuildMaxScale(key types.NamespacedName, mdb *mariadbv1alpha1.M Auth: ptr.Deref(mdbmxs.Auth, mariadbv1alpha1.MaxScaleAuth{}), Connection: mdbmxs.Connection, Metrics: mdbmxs.Metrics, + TLS: mdbmxs.TLS, Replicas: ptr.Deref(mdbmxs.Replicas, 1), PodDisruptionBudget: mdbmxs.PodDisruptionBudget, UpdateStrategy: mdbmxs.UpdateStrategy, @@ -42,6 +43,11 @@ func (b *Builder) BuildMaxScale(key types.NamespacedName, mdb *mariadbv1alpha1.M RequeueInterval: mdbmxs.RequeueInterval, }, } + if mxs.Spec.TLS == nil && mdb.IsTLSEnabled() { + mxs.Spec.TLS = &mariadbv1alpha1.MaxScaleTLS{ + Enabled: true, + } + } if err := controllerutil.SetControllerReference(mdb, &mxs, b.scheme); err != nil { return nil, fmt.Errorf("error setting controller to MaxScale %v", err) } diff --git a/pkg/builder/maxscale_builder_test.go b/pkg/builder/maxscale_builder_test.go index 4a549fb239..f272691612 100644 --- a/pkg/builder/maxscale_builder_test.go +++ b/pkg/builder/maxscale_builder_test.go @@ -60,3 +60,67 @@ func TestMaxScaleMeta(t *testing.T) { }) } } + +func TestMaxScaleTLS(t *testing.T) { + builder := newDefaultTestBuilder(t) + key := types.NamespacedName{ + Name: "maxscale", + } + tests := []struct { + name string + mariadb *mariadbv1alpha1.MariaDB + mdbmxs *mariadbv1alpha1.MariaDBMaxScaleSpec + wantTLS *mariadbv1alpha1.MaxScaleTLS + }{ + { + name: "tls enabled in MariaDB", + mariadb: &mariadbv1alpha1.MariaDB{ + Spec: mariadbv1alpha1.MariaDBSpec{ + TLS: &mariadbv1alpha1.TLS{ + Enabled: true, + }, + }, + }, + mdbmxs: &mariadbv1alpha1.MariaDBMaxScaleSpec{}, + wantTLS: &mariadbv1alpha1.MaxScaleTLS{Enabled: true}, + }, + { + name: "tls not enabled in MariaDB", + mariadb: &mariadbv1alpha1.MariaDB{ + Spec: mariadbv1alpha1.MariaDBSpec{}, + }, + mdbmxs: &mariadbv1alpha1.MariaDBMaxScaleSpec{}, + wantTLS: nil, + }, + { + name: "tls explicitly set in MaxScaleSpec", + mariadb: &mariadbv1alpha1.MariaDB{ + Spec: mariadbv1alpha1.MariaDBSpec{}, + }, + mdbmxs: &mariadbv1alpha1.MariaDBMaxScaleSpec{ + TLS: &mariadbv1alpha1.MaxScaleTLS{Enabled: true}, + }, + wantTLS: &mariadbv1alpha1.MaxScaleTLS{Enabled: true}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mxs, err := builder.BuildMaxScale(key, tt.mariadb, tt.mdbmxs) + if err != nil { + t.Fatalf("unexpected error building MaxScale: %v", err) + } + if tt.wantTLS == nil { + if mxs.Spec.TLS != nil { + t.Errorf("expected TLS to be nil, got %v", mxs.Spec.TLS) + } + } else { + if mxs.Spec.TLS == nil { + t.Errorf("expected TLS to be %v, got nil", tt.wantTLS) + } else if mxs.Spec.TLS.Enabled != tt.wantTLS.Enabled { + t.Errorf("expected TLS enabled to be %v, got %v", tt.wantTLS.Enabled, mxs.Spec.TLS.Enabled) + } + } + }) + } +} diff --git a/pkg/builder/pki/pki.go b/pkg/builder/pki/pki.go new file mode 100644 index 0000000000..f7fb392533 --- /dev/null +++ b/pkg/builder/pki/pki.go @@ -0,0 +1,40 @@ +package pki + +import ( + "path/filepath" + + "github.com/mariadb-operator/mariadb-operator/pkg/pki" +) + +const ( + PKIVolume = "pki" + PKIMountPath = "/etc/pki" + + ServerCertKey = "server.crt" + ServerKeyKey = "server.key" + + ClientCertKey = "client.crt" + ClientKeyKey = "client.key" + + AdminCertKey = "admin.crt" + AdminKeyKey = "admin.key" + + ListenerCertKey = "listener.crt" + ListenerKeyKey = "listener.key" +) + +var ( + CACertPath = filepath.Join(PKIMountPath, pki.CACertKey) + + ServerCertPath = filepath.Join(PKIMountPath, ServerCertKey) + ServerKeyPath = filepath.Join(PKIMountPath, ServerKeyKey) + + ClientCertPath = filepath.Join(PKIMountPath, ClientCertKey) + ClientKeyPath = filepath.Join(PKIMountPath, ClientKeyKey) + + AdminCertPath = filepath.Join(PKIMountPath, AdminCertKey) + AdminKeyPath = filepath.Join(PKIMountPath, AdminKeyKey) + + ListenerCertPath = filepath.Join(PKIMountPath, ListenerCertKey) + ListenerKeyPath = filepath.Join(PKIMountPath, ListenerKeyKey) +) diff --git a/pkg/builder/pod_builder.go b/pkg/builder/pod_builder.go index 4b7ec837a5..4115b78fb3 100644 --- a/pkg/builder/pod_builder.go +++ b/pkg/builder/pod_builder.go @@ -7,8 +7,10 @@ import ( mariadbv1alpha1 "github.com/mariadb-operator/mariadb-operator/api/v1alpha1" labels "github.com/mariadb-operator/mariadb-operator/pkg/builder/labels" metadata "github.com/mariadb-operator/mariadb-operator/pkg/builder/metadata" + builderpki "github.com/mariadb-operator/mariadb-operator/pkg/builder/pki" galeraresources "github.com/mariadb-operator/mariadb-operator/pkg/controller/galera/resources" kadapter "github.com/mariadb-operator/mariadb-operator/pkg/kubernetes/adapter" + "github.com/mariadb-operator/mariadb-operator/pkg/pki" corev1 "k8s.io/api/core/v1" "k8s.io/utils/ptr" "sigs.k8s.io/controller-runtime/pkg/client" @@ -217,7 +219,7 @@ func (b *Builder) mariadbPodTemplate(mariadb *mariadbv1alpha1.MariaDB, opts ...m }, nil } -func (b *Builder) maxscalePodTemplate(mxs *mariadbv1alpha1.MaxScale) (*corev1.PodTemplateSpec, error) { +func (b *Builder) maxscalePodTemplate(mxs *mariadbv1alpha1.MaxScale, annotations map[string]string) (*corev1.PodTemplateSpec, error) { containers, err := b.maxscaleContainers(mxs) if err != nil { return nil, err @@ -231,6 +233,7 @@ func (b *Builder) maxscalePodTemplate(mxs *mariadbv1alpha1.MaxScale) (*corev1.Po metadata.NewMetadataBuilder(client.ObjectKeyFromObject(mxs)). WithMetadata(mxs.Spec.InheritMetadata). WithMetadata(mxs.Spec.PodMetadata). + WithAnnotations(annotations). WithLabels(selectorLabels). Build() @@ -311,6 +314,10 @@ func mariadbVolumes(mariadb *mariadbv1alpha1.MariaDB, opts ...mariadbPodOpt) []c volumes := []corev1.Volume{ mariadbConfigVolume(mariadb), } + if mariadb.IsTLSEnabled() { + tlsVolumes, _ := mariadbTLSVolumes(mariadb) + volumes = append(volumes, tlsVolumes...) + } if mariadb.Replication().Enabled && ptr.Deref(mariadb.Replication().ProbesEnabled, false) { volumes = append(volumes, corev1.Volume{ Name: ProbesVolume, @@ -433,6 +440,22 @@ func mariadbConfigVolume(mariadb *mariadbv1alpha1.MariaDB) corev1.Volume { }, }) } + if mariadb.IsTLSEnabled() { + configMapKeyRef := mariadb.TLSConfigMapKeyRef() + projections = append(projections, corev1.VolumeProjection{ + ConfigMap: &corev1.ConfigMapProjection{ + LocalObjectReference: corev1.LocalObjectReference{ + Name: configMapKeyRef.Name, + }, + Items: []corev1.KeyToPath{ + { + Key: configMapKeyRef.Key, + Path: configMapKeyRef.Key, + }, + }, + }, + }) + } return corev1.Volume{ Name: ConfigVolume, VolumeSource: corev1.VolumeSource{ @@ -443,6 +466,75 @@ func mariadbConfigVolume(mariadb *mariadbv1alpha1.MariaDB) corev1.Volume { } } +func mariadbTLSVolumes(mariadb *mariadbv1alpha1.MariaDB) ([]corev1.Volume, []corev1.VolumeMount) { + if !mariadb.IsTLSEnabled() { + return nil, nil + } + return []corev1.Volume{ + { + Name: builderpki.PKIVolume, + VolumeSource: corev1.VolumeSource{ + Projected: &corev1.ProjectedVolumeSource{ + Sources: []corev1.VolumeProjection{ + { + Secret: &corev1.SecretProjection{ + LocalObjectReference: corev1.LocalObjectReference{ + Name: mariadb.TLSCABundleSecretKeyRef().Name, + }, + Items: []corev1.KeyToPath{ + { + Key: pki.CACertKey, + Path: pki.CACertKey, + }, + }, + }, + }, + { + Secret: &corev1.SecretProjection{ + LocalObjectReference: corev1.LocalObjectReference{ + Name: mariadb.TLSClientCertSecretKey().Name, + }, + Items: []corev1.KeyToPath{ + { + Key: pki.TLSCertKey, + Path: builderpki.ClientCertKey, + }, + { + Key: pki.TLSKeyKey, + Path: builderpki.ClientKeyKey, + }, + }, + }, + }, + { + Secret: &corev1.SecretProjection{ + LocalObjectReference: corev1.LocalObjectReference{ + Name: mariadb.TLSServerCertSecretKey().Name, + }, + Items: []corev1.KeyToPath{ + { + Key: pki.TLSCertKey, + Path: builderpki.ServerCertKey, + }, + { + Key: pki.TLSKeyKey, + Path: builderpki.ServerKeyKey, + }, + }, + }, + }, + }, + }, + }, + }, + }, []corev1.VolumeMount{ + { + Name: builderpki.PKIVolume, + MountPath: builderpki.PKIMountPath, + }, + } +} + func maxscaleVolumes(maxscale *mariadbv1alpha1.MaxScale) []corev1.Volume { volumes := []corev1.Volume{ { @@ -472,5 +564,95 @@ func maxscaleVolumes(maxscale *mariadbv1alpha1.MaxScale) []corev1.Volume { }, }, } + if maxscale.IsTLSEnabled() { + tlsVolumes, _ := maxscaleTLSVolumes(maxscale) + volumes = append(volumes, tlsVolumes...) + } return volumes } + +func maxscaleTLSVolumes(mxs *mariadbv1alpha1.MaxScale) ([]corev1.Volume, []corev1.VolumeMount) { + if !mxs.IsTLSEnabled() { + return nil, nil + } + return []corev1.Volume{ + { + Name: builderpki.PKIVolume, + VolumeSource: corev1.VolumeSource{ + Projected: &corev1.ProjectedVolumeSource{ + Sources: []corev1.VolumeProjection{ + { + Secret: &corev1.SecretProjection{ + LocalObjectReference: corev1.LocalObjectReference{ + Name: mxs.TLSCABundleSecretKeyRef().Name, + }, + Items: []corev1.KeyToPath{ + { + Key: pki.CACertKey, + Path: pki.CACertKey, + }, + }, + }, + }, + { + Secret: &corev1.SecretProjection{ + LocalObjectReference: corev1.LocalObjectReference{ + Name: mxs.TLSAdminCertSecretKey().Name, + }, + Items: []corev1.KeyToPath{ + { + Key: pki.TLSCertKey, + Path: builderpki.AdminCertKey, + }, + { + Key: pki.TLSKeyKey, + Path: builderpki.AdminKeyKey, + }, + }, + }, + }, + { + Secret: &corev1.SecretProjection{ + LocalObjectReference: corev1.LocalObjectReference{ + Name: mxs.TLSListenerCertSecretKey().Name, + }, + Items: []corev1.KeyToPath{ + { + Key: pki.TLSCertKey, + Path: builderpki.ListenerCertKey, + }, + { + Key: pki.TLSKeyKey, + Path: builderpki.ListenerKeyKey, + }, + }, + }, + }, + { + Secret: &corev1.SecretProjection{ + LocalObjectReference: corev1.LocalObjectReference{ + Name: mxs.TLSServerCertSecretKey().Name, + }, + Items: []corev1.KeyToPath{ + { + Key: pki.TLSCertKey, + Path: builderpki.ServerCertKey, + }, + { + Key: pki.TLSKeyKey, + Path: builderpki.ServerKeyKey, + }, + }, + }, + }, + }, + }, + }, + }, + }, []corev1.VolumeMount{ + { + Name: builderpki.PKIVolume, + MountPath: builderpki.PKIMountPath, + }, + } +} diff --git a/pkg/builder/pod_builder_test.go b/pkg/builder/pod_builder_test.go index 9b9cbd74f4..8251d461ba 100644 --- a/pkg/builder/pod_builder_test.go +++ b/pkg/builder/pod_builder_test.go @@ -7,6 +7,7 @@ import ( mariadbv1alpha1 "github.com/mariadb-operator/mariadb-operator/api/v1alpha1" "github.com/mariadb-operator/mariadb-operator/pkg/datastructures" "github.com/mariadb-operator/mariadb-operator/pkg/discovery" + "github.com/mariadb-operator/mariadb-operator/pkg/metadata" corev1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/api/resource" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -357,15 +358,17 @@ func TestMaxScalePodMeta(t *testing.T) { Name: "maxscale-obj", } tests := []struct { - name string - maxscale *mariadbv1alpha1.MaxScale - wantMeta *mariadbv1alpha1.Metadata + name string + maxscale *mariadbv1alpha1.MaxScale + annotations map[string]string + wantMeta *mariadbv1alpha1.Metadata }{ { name: "empty", maxscale: &mariadbv1alpha1.MaxScale{ ObjectMeta: objMeta, }, + annotations: nil, wantMeta: &mariadbv1alpha1.Metadata{ Labels: map[string]string{ "app.kubernetes.io/name": "maxscale", @@ -389,6 +392,7 @@ func TestMaxScalePodMeta(t *testing.T) { }, }, }, + annotations: nil, wantMeta: &mariadbv1alpha1.Metadata{ Labels: map[string]string{ "app.kubernetes.io/name": "maxscale", @@ -417,6 +421,7 @@ func TestMaxScalePodMeta(t *testing.T) { }, }, }, + annotations: nil, wantMeta: &mariadbv1alpha1.Metadata{ Labels: map[string]string{ "app.kubernetes.io/name": "maxscale", @@ -428,6 +433,24 @@ func TestMaxScalePodMeta(t *testing.T) { }, }, }, + { + name: "annotations", + maxscale: &mariadbv1alpha1.MaxScale{ + ObjectMeta: objMeta, + }, + annotations: map[string]string{ + metadata.TLSServerCertAnnotation: "cert", + }, + wantMeta: &mariadbv1alpha1.Metadata{ + Labels: map[string]string{ + "app.kubernetes.io/name": "maxscale", + "app.kubernetes.io/instance": "maxscale-obj", + }, + Annotations: map[string]string{ + metadata.TLSServerCertAnnotation: "cert", + }, + }, + }, { name: "inherit and Pod meta", maxscale: &mariadbv1alpha1.MaxScale{ @@ -447,6 +470,7 @@ func TestMaxScalePodMeta(t *testing.T) { }, }, }, + annotations: nil, wantMeta: &mariadbv1alpha1.Metadata{ Labels: map[string]string{ "app.kubernetes.io/name": "maxscale", @@ -480,6 +504,7 @@ func TestMaxScalePodMeta(t *testing.T) { }, }, }, + annotations: nil, wantMeta: &mariadbv1alpha1.Metadata{ Labels: map[string]string{ "app.kubernetes.io/name": "maxscale", @@ -516,6 +541,9 @@ func TestMaxScalePodMeta(t *testing.T) { }, }, }, + annotations: map[string]string{ + metadata.TLSServerCertAnnotation: "cert", + }, wantMeta: &mariadbv1alpha1.Metadata{ Labels: map[string]string{ "app.kubernetes.io/name": "maxscale", @@ -524,8 +552,9 @@ func TestMaxScalePodMeta(t *testing.T) { "k8s.mariadb.com": "test", }, Annotations: map[string]string{ - "k8s.mariadb.com": "test", - "database.myorg.io": "mariadb", + "k8s.mariadb.com": "test", + "database.myorg.io": "mariadb", + metadata.TLSServerCertAnnotation: "cert", }, }, }, @@ -533,7 +562,7 @@ func TestMaxScalePodMeta(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - podTpl, err := builder.maxscalePodTemplate(tt.maxscale) + podTpl, err := builder.maxscalePodTemplate(tt.maxscale, tt.annotations) if err != nil { t.Fatalf("unexpected error building MaxScale Pod template: %v", err) } @@ -1151,7 +1180,7 @@ func TestMaxscalePodBuilder(t *testing.T) { }, } - podTpl, err := builder.maxscalePodTemplate(mxs) + podTpl, err := builder.maxscalePodTemplate(mxs, nil) if err != nil { t.Fatalf("unexpected error building MaxScale Pod template: %v", err) } @@ -1187,7 +1216,7 @@ func TestMaxscaleEnterprisePodBuilder(t *testing.T) { }, } - podTpl, err := builder.maxscalePodTemplate(mxs) + podTpl, err := builder.maxscalePodTemplate(mxs, nil) if err != nil { t.Fatalf("unexpected error building MaxScale Pod template: %v", err) } diff --git a/pkg/builder/statefulset_builder.go b/pkg/builder/statefulset_builder.go index 2e96cc25aa..6135ef356b 100644 --- a/pkg/builder/statefulset_builder.go +++ b/pkg/builder/statefulset_builder.go @@ -105,7 +105,8 @@ func (b *Builder) BuildMariadbStatefulSet(mariadb *mariadbv1alpha1.MariaDB, key return sts, nil } -func (b *Builder) BuildMaxscaleStatefulSet(maxscale *mariadbv1alpha1.MaxScale, key types.NamespacedName) (*appsv1.StatefulSet, error) { +func (b *Builder) BuildMaxscaleStatefulSet(maxscale *mariadbv1alpha1.MaxScale, key types.NamespacedName, + podAnnotations map[string]string) (*appsv1.StatefulSet, error) { objMeta := metadata.NewMetadataBuilder(key). WithMetadata(maxscale.Spec.InheritMetadata). @@ -114,7 +115,7 @@ func (b *Builder) BuildMaxscaleStatefulSet(maxscale *mariadbv1alpha1.MaxScale, k labels.NewLabelsBuilder(). WithMaxScaleSelectorLabels(maxscale). Build() - podTemplate, err := b.maxscalePodTemplate(maxscale) + podTemplate, err := b.maxscalePodTemplate(maxscale, podAnnotations) if err != nil { return nil, err } diff --git a/pkg/builder/statefulset_builder_test.go b/pkg/builder/statefulset_builder_test.go index d11baf7122..d1bb058a71 100644 --- a/pkg/builder/statefulset_builder_test.go +++ b/pkg/builder/statefulset_builder_test.go @@ -6,6 +6,7 @@ import ( mariadbv1alpha1 "github.com/mariadb-operator/mariadb-operator/api/v1alpha1" galeraresources "github.com/mariadb-operator/mariadb-operator/pkg/controller/galera/resources" + "github.com/mariadb-operator/mariadb-operator/pkg/metadata" appsv1 "k8s.io/api/apps/v1" corev1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/api/resource" @@ -122,7 +123,7 @@ func TestMaxScaleImagePullSecrets(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - job, err := builder.BuildMaxscaleStatefulSet(tt.maxScale, client.ObjectKeyFromObject(tt.maxScale)) + job, err := builder.BuildMaxscaleStatefulSet(tt.maxScale, client.ObjectKeyFromObject(tt.maxScale), nil) if err != nil { t.Fatalf("unexpected error building StatefulSet: %v", err) } @@ -455,19 +456,29 @@ func TestMaxScaleStatefulSetMeta(t *testing.T) { Name: "maxscale-obj", } tests := []struct { - name string - maxscale *mariadbv1alpha1.MaxScale - wantMeta *mariadbv1alpha1.Metadata + name string + maxscale *mariadbv1alpha1.MaxScale + podAnnotations map[string]string + wantMeta *mariadbv1alpha1.Metadata + wantPodMeta *mariadbv1alpha1.Metadata }{ { name: "empty", maxscale: &mariadbv1alpha1.MaxScale{ ObjectMeta: objMeta, }, + podAnnotations: nil, wantMeta: &mariadbv1alpha1.Metadata{ Labels: map[string]string{}, Annotations: map[string]string{}, }, + wantPodMeta: &mariadbv1alpha1.Metadata{ + Labels: map[string]string{ + "app.kubernetes.io/instance": "maxscale-obj", + "app.kubernetes.io/name": "maxscale", + }, + Annotations: map[string]string{}, + }, }, { name: "inherit meta", @@ -484,6 +495,7 @@ func TestMaxScaleStatefulSetMeta(t *testing.T) { }, }, }, + podAnnotations: nil, wantMeta: &mariadbv1alpha1.Metadata{ Labels: map[string]string{ "sidecar.istio.io/inject": "false", @@ -492,16 +504,49 @@ func TestMaxScaleStatefulSetMeta(t *testing.T) { "database.myorg.io": "mariadb", }, }, + wantPodMeta: &mariadbv1alpha1.Metadata{ + Labels: map[string]string{ + "app.kubernetes.io/instance": "maxscale-obj", + "app.kubernetes.io/name": "maxscale", + "sidecar.istio.io/inject": "false", + }, + Annotations: map[string]string{ + "database.myorg.io": "mariadb", + }, + }, + }, + { + name: "Pod annotations", + maxscale: &mariadbv1alpha1.MaxScale{ + ObjectMeta: objMeta, + }, + podAnnotations: map[string]string{ + metadata.TLSServerCertAnnotation: "cert", + }, + wantMeta: &mariadbv1alpha1.Metadata{ + Labels: map[string]string{}, + Annotations: map[string]string{}, + }, + wantPodMeta: &mariadbv1alpha1.Metadata{ + Labels: map[string]string{ + "app.kubernetes.io/instance": "maxscale-obj", + "app.kubernetes.io/name": "maxscale", + }, + Annotations: map[string]string{ + metadata.TLSServerCertAnnotation: "cert", + }, + }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - sts, err := builder.BuildMaxscaleStatefulSet(tt.maxscale, key) + sts, err := builder.BuildMaxscaleStatefulSet(tt.maxscale, key, tt.podAnnotations) if err != nil { t.Fatalf("unexpected error building MaxScale StatefulSet: %v", err) } assertObjectMeta(t, &sts.ObjectMeta, tt.wantMeta.Labels, tt.wantMeta.Annotations) + assertObjectMeta(t, &sts.Spec.Template.ObjectMeta, tt.wantPodMeta.Labels, tt.wantPodMeta.Annotations) }) } } diff --git a/pkg/command/backup.go b/pkg/command/backup.go index 8da0c71e6c..a7aff46aba 100644 --- a/pkg/command/backup.go +++ b/pkg/command/backup.go @@ -9,6 +9,7 @@ import ( mariadbv1alpha1 "github.com/mariadb-operator/mariadb-operator/api/v1alpha1" backuppkg "github.com/mariadb-operator/mariadb-operator/pkg/backup" + builderpki "github.com/mariadb-operator/mariadb-operator/pkg/builder/pki" ds "github.com/mariadb-operator/mariadb-operator/pkg/datastructures" "k8s.io/utils/ptr" ) @@ -219,7 +220,7 @@ func (b *BackupCommand) MariadbOperatorRestore() *Command { func (b *BackupCommand) MariadbRestore(restore *mariadbv1alpha1.Restore, mariadb *mariadbv1alpha1.MariaDB) *Command { - args := strings.Join(b.mariadbArgs(restore), " ") + args := strings.Join(b.mariadbArgs(restore, mariadb), " ") cmds := []string{ "set -euo pipefail", fmt.Sprintf( @@ -257,7 +258,7 @@ func (b *BackupCommand) getTargetFilePath() string { return fmt.Sprintf("$(cat '%s')", b.TargetFilePath) } -func (b *BackupCommand) mariadbDumpArgs(backup *mariadbv1alpha1.Backup, mariab *mariadbv1alpha1.MariaDB) []string { +func (b *BackupCommand) mariadbDumpArgs(backup *mariadbv1alpha1.Backup, mariadb *mariadbv1alpha1.MariaDB) []string { dumpOpts := make([]string, len(b.BackupOpts.DumpOpts)) copy(dumpOpts, b.BackupOpts.DumpOpts) @@ -282,7 +283,7 @@ func (b *BackupCommand) mariadbDumpArgs(backup *mariadbv1alpha1.Backup, mariab * } // LOCK TABLES is not compatible with Galera: https://mariadb.com/kb/en/lock-tables/#limitations - if mariab.IsGaleraEnabled() { + if mariadb.IsGaleraEnabled() { args = append(args, "--skip-add-locks") } // Galera only replicates InnoDB tables and mysql.global_priv uses the MyISAM engine. @@ -294,10 +295,14 @@ func (b *BackupCommand) mariadbDumpArgs(backup *mariadbv1alpha1.Backup, mariab * args = append(args, "--ignore-table=mysql.global_priv") } + if mariadb.IsTLSEnabled() { + args = append(args, b.tlsArgs(mariadb)...) + } + return ds.Unique(ds.Merge(args, dumpOpts)...) } -func (b *BackupCommand) mariadbArgs(restore *mariadbv1alpha1.Restore) []string { +func (b *BackupCommand) mariadbArgs(restore *mariadbv1alpha1.Restore, mariadb *mariadbv1alpha1.MariaDB) []string { args := make([]string, len(b.BackupOpts.DumpOpts)) copy(args, b.BackupOpts.DumpOpts) @@ -305,6 +310,10 @@ func (b *BackupCommand) mariadbArgs(restore *mariadbv1alpha1.Restore) []string { args = append(args, fmt.Sprintf("--one-database %s", restore.Spec.Database)) } + if mariadb.IsTLSEnabled() { + args = append(args, b.tlsArgs(mariadb)...) + } + return ds.Unique(args...) } @@ -344,3 +353,19 @@ func (b *BackupCommand) s3Args() []string { } return args } + +func (b *BackupCommand) tlsArgs(mariadb *mariadbv1alpha1.MariaDB) []string { + if !mariadb.IsTLSEnabled() { + return nil + } + return []string{ + "--ssl", + "--ssl-ca", + builderpki.CACertPath, + "--ssl-cert", + builderpki.ClientCertPath, + "--ssl-key", + builderpki.ClientKeyPath, + "--ssl-verify-server-cert", + } +} diff --git a/pkg/command/backup_test.go b/pkg/command/backup_test.go index d424462d92..503127a729 100644 --- a/pkg/command/backup_test.go +++ b/pkg/command/backup_test.go @@ -1,11 +1,12 @@ package command import ( - "reflect" "testing" "time" + "github.com/google/go-cmp/cmp" mariadbv1alpha1 "github.com/mariadb-operator/mariadb-operator/api/v1alpha1" + builderpki "github.com/mariadb-operator/mariadb-operator/pkg/builder/pki" "k8s.io/utils/ptr" ) @@ -69,6 +70,32 @@ func TestMariadbDumpArgs(t *testing.T) { "--skip-add-locks", }, }, + { + name: "TLS", + backupCmd: &BackupCommand{}, + backup: &mariadbv1alpha1.Backup{}, + mariadb: &mariadbv1alpha1.MariaDB{ + Spec: mariadbv1alpha1.MariaDBSpec{ + TLS: &mariadbv1alpha1.TLS{ + Enabled: true, + }, + }, + }, + wantArgs: []string{ + "--single-transaction", + "--events", + "--routines", + "--all-databases", + "--ssl", + "--ssl-ca", + builderpki.CACertPath, + "--ssl-cert", + builderpki.ClientCertPath, + "--ssl-key", + builderpki.ClientKeyPath, + "--ssl-verify-server-cert", + }, + }, { name: "ignore mysql.global_priv", backupCmd: &BackupCommand{}, @@ -253,8 +280,8 @@ func TestMariadbDumpArgs(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { args := tt.backupCmd.mariadbDumpArgs(tt.backup, tt.mariadb) - if !reflect.DeepEqual(args, tt.wantArgs) { - t.Errorf("expecting args to be:\n%v\ngot:\n%v\n", tt.wantArgs, args) + if diff := cmp.Diff(args, tt.wantArgs); diff != "" { + t.Errorf("unexpected args (-want +got):\n%s", diff) } }) } @@ -386,8 +413,8 @@ func TestMariadbOperatorBackup(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { command := tt.backupCmd.MariadbOperatorBackup() - if !reflect.DeepEqual(tt.wantArgs, command.Args) { - t.Errorf("expecting args to be:\n%v\ngot:\n%v\n", tt.wantArgs, command.Args) + if diff := cmp.Diff(command.Args, tt.wantArgs); diff != "" { + t.Errorf("unexpected args (-want +got):\n%s", diff) } }) } @@ -398,12 +425,14 @@ func TestMariadbArgs(t *testing.T) { name string backupCmd *BackupCommand restore *mariadbv1alpha1.Restore + mariadb *mariadbv1alpha1.MariaDB wantArgs []string }{ { name: "empty", backupCmd: &BackupCommand{}, restore: &mariadbv1alpha1.Restore{}, + mariadb: &mariadbv1alpha1.MariaDB{}, wantArgs: nil, }, { @@ -417,6 +446,7 @@ func TestMariadbArgs(t *testing.T) { }, }, restore: &mariadbv1alpha1.Restore{}, + mariadb: &mariadbv1alpha1.MariaDB{}, wantArgs: []string{ "--verbose", "--one-database db1", @@ -434,6 +464,7 @@ func TestMariadbArgs(t *testing.T) { }, }, restore: &mariadbv1alpha1.Restore{}, + mariadb: &mariadbv1alpha1.MariaDB{}, wantArgs: []string{ "--verbose", "--one-database db1", @@ -447,6 +478,7 @@ func TestMariadbArgs(t *testing.T) { Database: "db1", }, }, + mariadb: &mariadbv1alpha1.MariaDB{}, wantArgs: []string{ "--one-database db1", }, @@ -465,18 +497,41 @@ func TestMariadbArgs(t *testing.T) { Database: "db1", }, }, + mariadb: &mariadbv1alpha1.MariaDB{}, wantArgs: []string{ "--verbose", "--one-database db1", }, }, + { + name: "TLS", + backupCmd: &BackupCommand{}, + restore: &mariadbv1alpha1.Restore{}, + mariadb: &mariadbv1alpha1.MariaDB{ + Spec: mariadbv1alpha1.MariaDBSpec{ + TLS: &mariadbv1alpha1.TLS{ + Enabled: true, + }, + }, + }, + wantArgs: []string{ + "--ssl", + "--ssl-ca", + builderpki.CACertPath, + "--ssl-cert", + builderpki.ClientCertPath, + "--ssl-key", + builderpki.ClientKeyPath, + "--ssl-verify-server-cert", + }, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - args := tt.backupCmd.mariadbArgs(tt.restore) - if !reflect.DeepEqual(args, tt.wantArgs) { - t.Errorf("expecting args to be:\n%v\ngot:\n%v\n", tt.wantArgs, args) + args := tt.backupCmd.mariadbArgs(tt.restore, tt.mariadb) + if diff := cmp.Diff(args, tt.wantArgs); diff != "" { + t.Errorf("unexpected args (-want +got):\n%s", diff) } }) } diff --git a/pkg/controller/certificate/cert_manager.go b/pkg/controller/certificate/cert_manager.go new file mode 100644 index 0000000000..27df22c11e --- /dev/null +++ b/pkg/controller/certificate/cert_manager.go @@ -0,0 +1,189 @@ +package certificate + +import ( + "context" + "crypto/x509" + "errors" + "fmt" + "time" + + certmanagerv1 "github.com/cert-manager/cert-manager/pkg/apis/certmanager/v1" + cmmeta "github.com/cert-manager/cert-manager/pkg/apis/meta/v1" + "github.com/go-logr/logr" + mariadbv1alpha1 "github.com/mariadb-operator/mariadb-operator/api/v1alpha1" + "github.com/mariadb-operator/mariadb-operator/pkg/builder" + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +func (r *CertReconciler) reconcileCertManagerCert(ctx context.Context, opts *CertReconcilerOpts, logger logr.Logger) (ctrl.Result, error) { + if r.discovery == nil || r.builder == nil { + return ctrl.Result{}, errors.New("discovery and builder must be initialized") + } + + certExists, err := r.discovery.CertificateExist() + if err != nil { + return ctrl.Result{}, fmt.Errorf("error checking Certificate availability in the cluster: %w", err) + } + if !certExists { + r.recorder.Event(opts.relatedObject, corev1.EventTypeWarning, mariadbv1alpha1.ReasonCRDNotFound, + "Unable to reconcile certificate: Certificate CRD not installed in the cluster") + logger.Error(errors.New("Certificate CRD not installed in the cluster"), "Unable to reconcile certificate") + return ctrl.Result{RequeueAfter: 10 * time.Second}, nil + } + + desiredCert, err := r.buildCertManagerCert(opts, logger) + if err != nil { + return ctrl.Result{}, fmt.Errorf("error building desired cert: %v", err) + } + if err := r.reconcileCertManagerDesiredCert(ctx, opts, desiredCert); err != nil { + return ctrl.Result{}, fmt.Errorf("error reconciling desired cert: %v", err) + } + + if err := r.certManagerCertReady(ctx, opts); err != nil { + logger.V(1).Info("Certificate not ready. Requeuing...", "err", err) + return ctrl.Result{RequeueAfter: 5 * time.Second}, nil + } + + return ctrl.Result{}, nil +} + +func (r *CertReconciler) buildCertManagerCert(opts *CertReconcilerOpts, logger logr.Logger) (*certmanagerv1.Certificate, error) { + certOpts := []builder.CertOpt{ + builder.WithKey(opts.certSecretKey), + builder.WithOwner(opts.relatedObject), + builder.WithDNSnames(opts.certDNSNames), + builder.WithLifetime(opts.certLifetime), + builder.WithUsages(certManagerKeyUsages(opts, logger)...), + builder.WithIssuerRef(*opts.certIssuerRef), + } + cert, err := r.builder.BuildCertificate(certOpts...) + if err != nil { + return nil, fmt.Errorf("error building Certificate: %v", err) + } + return cert, nil +} + +func (r *CertReconciler) reconcileCertManagerDesiredCert(ctx context.Context, opts *CertReconcilerOpts, + desiredCert *certmanagerv1.Certificate) error { + var existingCert certmanagerv1.Certificate + if err := r.Get(ctx, opts.certSecretKey, &existingCert); err != nil { + if !apierrors.IsNotFound(err) { + return fmt.Errorf("error getting Certificate: %v", err) + } + if err := r.Create(ctx, desiredCert); err != nil { + return fmt.Errorf("error creating Certificate: %v", err) + } + return nil + } + + patch := client.MergeFrom(existingCert.DeepCopy()) + existingCert.Spec.Duration = desiredCert.Spec.Duration + existingCert.Spec.DNSNames = desiredCert.Spec.DNSNames + existingCert.Spec.CommonName = desiredCert.Spec.CommonName + existingCert.Spec.Usages = desiredCert.Spec.Usages + existingCert.Spec.IssuerRef = desiredCert.Spec.IssuerRef + existingCert.Spec.SecretName = desiredCert.Spec.SecretName + return r.Patch(ctx, &existingCert, patch) +} + +func (r *CertReconciler) certManagerCertReady(ctx context.Context, opts *CertReconcilerOpts) error { + var cert certmanagerv1.Certificate + if err := r.Get(ctx, opts.certSecretKey, &cert); err != nil { + return fmt.Errorf("error getting cert: %w", err) + } + for _, condition := range cert.Status.Conditions { + if condition.Type != certmanagerv1.CertificateConditionReady { + continue + } + if condition.Status == cmmeta.ConditionTrue { + return nil + } else { + return fmt.Errorf("Certificate '%s' not ready: %s", opts.certSecretKey.Name, condition.Message) + } + } + return fmt.Errorf("Certificate '%s' not ready", opts.certSecretKey.Name) +} + +func certManagerKeyUsages(opts *CertReconcilerOpts, logger logr.Logger) []certmanagerv1.KeyUsage { + var usages []certmanagerv1.KeyUsage + if opts.certKeyUsage != 0 { + usages = append(usages, mapX509KeyUsageToCertManager(opts.certKeyUsage)...) + } + usages = append(usages, mapX509ExtKeyUsageToCertManager(opts.certExtKeyUsage, logger)...) + return usages +} + +func mapX509KeyUsageToCertManager(usage x509.KeyUsage) []certmanagerv1.KeyUsage { + var cmUsages []certmanagerv1.KeyUsage + + if usage&x509.KeyUsageDigitalSignature != 0 { + cmUsages = append(cmUsages, certmanagerv1.UsageDigitalSignature) + } + if usage&x509.KeyUsageContentCommitment != 0 { + cmUsages = append(cmUsages, certmanagerv1.UsageContentCommitment) + } + if usage&x509.KeyUsageKeyEncipherment != 0 { + cmUsages = append(cmUsages, certmanagerv1.UsageKeyEncipherment) + } + if usage&x509.KeyUsageDataEncipherment != 0 { + cmUsages = append(cmUsages, certmanagerv1.UsageDataEncipherment) + } + if usage&x509.KeyUsageKeyAgreement != 0 { + cmUsages = append(cmUsages, certmanagerv1.UsageKeyAgreement) + } + if usage&x509.KeyUsageCertSign != 0 { + cmUsages = append(cmUsages, certmanagerv1.UsageCertSign) + } + if usage&x509.KeyUsageCRLSign != 0 { + cmUsages = append(cmUsages, certmanagerv1.UsageCRLSign) + } + if usage&x509.KeyUsageEncipherOnly != 0 { + cmUsages = append(cmUsages, certmanagerv1.UsageEncipherOnly) + } + if usage&x509.KeyUsageDecipherOnly != 0 { + cmUsages = append(cmUsages, certmanagerv1.UsageDecipherOnly) + } + + return cmUsages +} + +func mapX509ExtKeyUsageToCertManager(extUsages []x509.ExtKeyUsage, logger logr.Logger) []certmanagerv1.KeyUsage { + var cmUsages []certmanagerv1.KeyUsage + + for _, usage := range extUsages { + switch usage { + case x509.ExtKeyUsageAny: + cmUsages = append(cmUsages, certmanagerv1.UsageAny) + case x509.ExtKeyUsageServerAuth: + cmUsages = append(cmUsages, certmanagerv1.UsageServerAuth) + case x509.ExtKeyUsageClientAuth: + cmUsages = append(cmUsages, certmanagerv1.UsageClientAuth) + case x509.ExtKeyUsageCodeSigning: + cmUsages = append(cmUsages, certmanagerv1.UsageCodeSigning) + case x509.ExtKeyUsageEmailProtection: + cmUsages = append(cmUsages, certmanagerv1.UsageEmailProtection) + case x509.ExtKeyUsageIPSECEndSystem: + cmUsages = append(cmUsages, certmanagerv1.UsageIPsecEndSystem) + case x509.ExtKeyUsageIPSECTunnel: + cmUsages = append(cmUsages, certmanagerv1.UsageIPsecTunnel) + case x509.ExtKeyUsageIPSECUser: + cmUsages = append(cmUsages, certmanagerv1.UsageIPsecUser) + case x509.ExtKeyUsageTimeStamping: + cmUsages = append(cmUsages, certmanagerv1.UsageTimestamping) + case x509.ExtKeyUsageOCSPSigning: + cmUsages = append(cmUsages, certmanagerv1.UsageOCSPSigning) + case x509.ExtKeyUsageMicrosoftServerGatedCrypto: + cmUsages = append(cmUsages, certmanagerv1.UsageMicrosoftSGC) + case x509.ExtKeyUsageNetscapeServerGatedCrypto: + cmUsages = append(cmUsages, certmanagerv1.UsageNetscapeSGC) + default: + logger.Error(errors.New("unsupported x509 key usage"), "Unsupported ExtKeyUsage encountered", "key-usage", usage) + continue + } + } + + return cmUsages +} diff --git a/pkg/controller/certificate/cert_manager_test.go b/pkg/controller/certificate/cert_manager_test.go new file mode 100644 index 0000000000..61d1abbb8a --- /dev/null +++ b/pkg/controller/certificate/cert_manager_test.go @@ -0,0 +1,77 @@ +package certificate + +import ( + "crypto/x509" + "testing" + + certmanagerv1 "github.com/cert-manager/cert-manager/pkg/apis/certmanager/v1" + "github.com/go-logr/logr" +) + +func TestCertManagerKeyUsages(t *testing.T) { + tests := []struct { + name string + opts *CertReconcilerOpts + expectedUsages []certmanagerv1.KeyUsage + }{ + { + name: "No key usages", + opts: &CertReconcilerOpts{ + certKeyUsage: 0, + certExtKeyUsage: nil, + }, + expectedUsages: []certmanagerv1.KeyUsage{}, + }, + { + name: "Single key usage: DigitalSignature", + opts: &CertReconcilerOpts{ + certKeyUsage: x509.KeyUsageDigitalSignature, + certExtKeyUsage: nil, + }, + expectedUsages: []certmanagerv1.KeyUsage{certmanagerv1.UsageDigitalSignature}, + }, + { + name: "Multiple key usages", + opts: &CertReconcilerOpts{ + certKeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment, + certExtKeyUsage: []x509.ExtKeyUsage{ + x509.ExtKeyUsageServerAuth, + x509.ExtKeyUsageClientAuth, + }, + }, + expectedUsages: []certmanagerv1.KeyUsage{ + certmanagerv1.UsageDigitalSignature, + certmanagerv1.UsageKeyEncipherment, + certmanagerv1.UsageServerAuth, + certmanagerv1.UsageClientAuth, + }, + }, + { + name: "Unsupported ExtKeyUsage", + opts: &CertReconcilerOpts{ + certKeyUsage: 0, + certExtKeyUsage: []x509.ExtKeyUsage{ + x509.ExtKeyUsageTimeStamping, + 99, // Unsupported ExtKeyUsage + }, + }, + expectedUsages: []certmanagerv1.KeyUsage{certmanagerv1.UsageTimestamping}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + usages := certManagerKeyUsages(tt.opts, logr.Discard()) + + if len(usages) != len(tt.expectedUsages) { + t.Errorf("unexpected number of usages: got %d, want %d", len(usages), len(tt.expectedUsages)) + } + + for i, usage := range usages { + if usage != tt.expectedUsages[i] { + t.Errorf("unexpected usage at index %d: got %v, want %v", i, usage, tt.expectedUsages[i]) + } + } + }) + } +} diff --git a/pkg/controller/certificate/controller.go b/pkg/controller/certificate/controller.go index 4d1737e888..6a29072c4f 100644 --- a/pkg/controller/certificate/controller.go +++ b/pkg/controller/certificate/controller.go @@ -2,202 +2,405 @@ package certificate import ( "context" + "crypto/x509" + "errors" "fmt" "time" + "github.com/go-logr/logr" + mariadbv1alpha1 "github.com/mariadb-operator/mariadb-operator/api/v1alpha1" + "github.com/mariadb-operator/mariadb-operator/pkg/builder" + "github.com/mariadb-operator/mariadb-operator/pkg/discovery" + "github.com/mariadb-operator/mariadb-operator/pkg/metadata" "github.com/mariadb-operator/mariadb-operator/pkg/pki" + "github.com/mariadb-operator/mariadb-operator/pkg/refresolver" corev1 "k8s.io/api/core/v1" apierrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/types" + "k8s.io/client-go/tools/record" + ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" + "sigs.k8s.io/controller-runtime/pkg/log" ) var ( - defaultCAValidityDuration = 4 * 365 * 24 * time.Hour - defaultCertValidityDuration = 365 * 24 * time.Hour - defaultLookaheadValidity = 90 * 24 * time.Hour + ErrSkipCertRenewal = errors.New("skipping certificate renewal") ) -type CertReconcilerOpts struct { - caSecretKey types.NamespacedName - caCommonName string - caValidity time.Duration - - certSecretKey types.NamespacedName - certCommonName string - certDNSNames []string - certValidity time.Duration +type CertReconciler struct { + client.Client + scheme *runtime.Scheme + recorder record.EventRecorder + refResolver *refresolver.RefResolver + discovery *discovery.Discovery + builder *builder.Builder +} - lookaheadValidity time.Duration +func NewCertReconciler(client client.Client, scheme *runtime.Scheme, recorder record.EventRecorder, + discovery *discovery.Discovery, builder *builder.Builder) *CertReconciler { + return &CertReconciler{ + Client: client, + scheme: scheme, + recorder: recorder, + refResolver: refresolver.New(client), + discovery: discovery, + builder: builder, + } } -type CertReconcilerOpt func(opts *CertReconcilerOpts) +type ReconcileResult struct { + ctrl.Result + CAKeyPair *pki.KeyPair + CertKeyPair *pki.KeyPair +} -func WithCAValidity(validity time.Duration) CertReconcilerOpt { - return func(opts *CertReconcilerOpts) { - opts.caValidity = validity +func (r *ReconcileResult) IsZero() bool { + if r == nil { + return true } + return r.Result.IsZero() } -func WithCertValidity(validity time.Duration) CertReconcilerOpt { - return func(opts *CertReconcilerOpts) { - opts.certValidity = validity +func (r *CertReconciler) Reconcile(ctx context.Context, certOpts ...CertReconcilerOpt) (*ReconcileResult, error) { + opts := NewDefaultCertificateOpts() + for _, setOpt := range certOpts { + setOpt(opts) } -} + logger := log.FromContext(ctx).WithName("cert") + result := &ReconcileResult{} + var err error -func WithLookaheadValidity(validity time.Duration) CertReconcilerOpt { - return func(opts *CertReconcilerOpts) { - opts.lookaheadValidity = validity + if opts.certIssuerRef != nil { + result.Result, err = r.reconcileCertManagerCert(ctx, opts, logger) + if err != nil { + return nil, fmt.Errorf("error reconciling cert-manager Certificate: %v", err) + } + } else if opts.shouldIssueCA || opts.shouldIssueCert { + result.CAKeyPair, err = r.reconcileCA(ctx, opts, logger) + if err != nil { + return nil, fmt.Errorf("error reconciling CA: %v", err) + } + result.Result, result.CertKeyPair, err = r.reconcileCert(ctx, result.CAKeyPair, opts, logger) + if err != nil { + return nil, fmt.Errorf("error reconciling certificate: %v", err) + } } -} -type CertReconciler struct { - client.Client - CertReconcilerOpts + return result, nil } -func NewCertReconciler(client client.Client, caSecretKey types.NamespacedName, caCommonName string, - certSecretKey types.NamespacedName, certCommonName string, certDNSNames []string, - reconcilerOpts ...CertReconcilerOpt) *CertReconciler { - opts := CertReconcilerOpts{ - caSecretKey: caSecretKey, - caCommonName: caCommonName, - caValidity: defaultCAValidityDuration, +func (r *CertReconciler) reconcileCA(ctx context.Context, opts *CertReconcilerOpts, logger logr.Logger) (*pki.KeyPair, error) { + if !opts.shouldIssueCA && !opts.shouldIssueCert { + return nil, nil + } + if !opts.shouldIssueCA && opts.shouldIssueCert { + caKeyPair, err := r.getCAKeyPair(ctx, opts) + if err != nil { + return nil, fmt.Errorf("error getting CA keypair: %v", err) + } + return caKeyPair, nil + } - certSecretKey: certSecretKey, - certCommonName: certCommonName, - certValidity: defaultCertValidityDuration, - certDNSNames: certDNSNames, + createCA := r.createCAFn(opts) + caKeyPair, err := r.reconcileKeyPair(ctx, opts.caSecretKey, opts.caSecretType, false, opts, createCA) + if err != nil { + return nil, fmt.Errorf("Error reconciling CA keypair: %v", err) + } - lookaheadValidity: defaultLookaheadValidity, + caLeafCert, err := caKeyPair.LeafCertificate() + if err != nil { + return nil, fmt.Errorf("error getting CA leaf certificate: %v", err) } - for _, setOpt := range reconcilerOpts { - setOpt(&opts) + renewalTime, err := pki.RenewalTime(caLeafCert.NotBefore, caLeafCert.NotAfter, opts.renewBeforePercentage) + if err != nil { + return nil, fmt.Errorf("error getting CA renewal time: %v", err) } - return &CertReconciler{ - Client: client, - CertReconcilerOpts: opts, + valid, err := pki.ValidateCA(caKeyPair, opts.caCommonName, time.Now()) + afterRenewal := time.Now().After(*renewalTime) + caLogger := logger.WithValues( + "common-name", caLeafCert.Subject.CommonName, + "issuer", caLeafCert.Issuer.CommonName, + "valid", valid, + "err", err, + "renewal-time", renewalTime, + "after-renewal", afterRenewal, + ) + caLogger.V(1).Info("CA cert status") + + if !valid || err != nil || afterRenewal { + caLogger.Info("starting CA cert renewal") + + caKeyPair, err = r.reconcileKeyPair(ctx, opts.caSecretKey, opts.caSecretType, true, opts, createCA) + if err != nil { + return nil, fmt.Errorf("Error reconciling CA keypair: %v", err) + } } + return caKeyPair, nil } -type ReconcileResult struct { - CAKeyPair *pki.KeyPair - CertKeyPair *pki.KeyPair - RefreshedCA bool - RefreshedCert bool -} +func (r *CertReconciler) reconcileCert(ctx context.Context, caKeyPair *pki.KeyPair, opts *CertReconcilerOpts, + logger logr.Logger) (ctrl.Result, *pki.KeyPair, error) { + if !opts.shouldIssueCert { + return ctrl.Result{}, nil, nil + } + if caKeyPair == nil { + return ctrl.Result{}, nil, errors.New("unable to issue cert: CA keypair is nil") + } -func (r *CertReconciler) Reconcile(ctx context.Context) (*ReconcileResult, error) { - result := &ReconcileResult{} - var err error - result.CAKeyPair, result.RefreshedCA, err = r.reconcileKeyPair(ctx, r.caSecretKey, false, r.createCA) + createCert := r.createCertFn(caKeyPair, opts) + certKeyPair, err := r.reconcileKeyPair(ctx, opts.certSecretKey, SecretTypeTLS, false, opts, createCert) if err != nil { - return nil, fmt.Errorf("Error reconciling CA KeyPair: %v", err) + return ctrl.Result{}, nil, fmt.Errorf("Error reconciling certificate keypair: %v", err) } - valid, err := pki.ValidCACert(result.CAKeyPair, r.caCommonName, r.lookaheadTime()) + caCerts, err := r.getCABundle(ctx, caKeyPair, opts, logger) + if err != nil { + return ctrl.Result{}, nil, fmt.Errorf("Error getting CA bundle: %v", err) + } + leafCert, err := certKeyPair.LeafCertificate() + if err != nil { + return ctrl.Result{}, nil, fmt.Errorf("error getting leaf certificate: %v", err) + } + renewalTime, err := pki.RenewalTime(leafCert.NotBefore, leafCert.NotAfter, opts.renewBeforePercentage) + if err != nil { + return ctrl.Result{}, nil, fmt.Errorf("error getting cert renewal time: %v", err) + } + + valid, err := pki.ValidateCert(caCerts, certKeyPair, opts.certCommonName, time.Now()) + afterRenewal := time.Now().After(*renewalTime) + certLogger := logger.WithValues( + "common-name", leafCert.Subject.CommonName, + "issuer", leafCert.Issuer.CommonName, + "valid", valid, + "err", err, + "renewal-time", renewalTime, + "after-renewal", afterRenewal, + ) + certLogger.V(1).Info("cert status") + if !valid || err != nil { - result.CAKeyPair, result.RefreshedCA, err = r.reconcileKeyPair(ctx, r.caSecretKey, true, r.createCA) + certLogger.Info("starting cert renewal", "reason", "Invalid cert") + + certKeyPair, err = r.reconcileKeyPair(ctx, opts.certSecretKey, SecretTypeTLS, true, opts, createCert) if err != nil { - return nil, fmt.Errorf("Error reconciling CA KeyPair: %v", err) + return ctrl.Result{}, nil, fmt.Errorf("error reconciling certificate KeyPair: %v", err) + } + if err := opts.certHandler.HandleExpiredCert(ctx); err != nil { + return ctrl.Result{}, certKeyPair, fmt.Errorf("error handling expired certificate: %v", err) } + return ctrl.Result{}, certKeyPair, nil } - createCert := r.createCertFn(result.CAKeyPair) - result.CertKeyPair, result.RefreshedCert, err = r.reconcileKeyPair(ctx, r.certSecretKey, false, createCert) + if !afterRenewal { + return ctrl.Result{}, certKeyPair, nil + } + shouldRenew, reason, err := opts.certHandler.ShouldRenewCert(ctx, caKeyPair) if err != nil { - return nil, fmt.Errorf("Error reconciling certificate KeyPair: %v", err) + if errors.Is(err, ErrSkipCertRenewal) { + certLogger.V(1).Info("skipping cert renewal", "reason", reason) + + return ctrl.Result{}, certKeyPair, nil + } + return ctrl.Result{}, nil, fmt.Errorf("error checking whether certificate should be renewed: %v", err) + } + if !shouldRenew { + certLogger.Info("waiting for cert renewal", "reason", reason) + + return ctrl.Result{RequeueAfter: 10 * time.Second}, nil, nil } + if shouldRenew { + certLogger.Info("starting cert renewal", "reason", reason) - valid, err = pki.ValidCert(result.CAKeyPair.Cert, result.CertKeyPair, r.certCommonName, r.lookaheadTime()) - if result.RefreshedCA || !valid || err != nil { - result.CertKeyPair, result.RefreshedCert, err = r.reconcileKeyPair(ctx, r.certSecretKey, true, createCert) + certKeyPair, err = r.reconcileKeyPair(ctx, opts.certSecretKey, SecretTypeTLS, true, opts, createCert) if err != nil { - return nil, fmt.Errorf("Error reconciling certificate KeyPair: %v", err) + return ctrl.Result{}, nil, fmt.Errorf("error reconciling certificate KeyPair: %v", err) } + return ctrl.Result{}, certKeyPair, nil } - return result, nil + + return ctrl.Result{}, certKeyPair, nil } -func (r *CertReconciler) reconcileKeyPair(ctx context.Context, key types.NamespacedName, refresh bool, - createKeyPairFn func() (*pki.KeyPair, error)) (keyPair *pki.KeyPair, refreshed bool, err error) { +func (r *CertReconciler) reconcileKeyPair(ctx context.Context, key types.NamespacedName, secretType SecretType, + shouldRenew bool, opts *CertReconcilerOpts, createKeyPairFn func() (*pki.KeyPair, error)) (keyPair *pki.KeyPair, err error) { secret := corev1.Secret{} if err := r.Get(ctx, key, &secret); err != nil { if !apierrors.IsNotFound(err) { - return nil, false, err + return nil, err } keyPair, err := createKeyPairFn() if err != nil { - return nil, false, err + return nil, err } - if err := r.createSecret(ctx, key, &secret, keyPair); err != nil { - return nil, false, err + if err := r.createSecret(ctx, key, secretType, &secret, keyPair, opts.relatedObject); err != nil { + return nil, err } - return keyPair, true, nil + return keyPair, nil } - if secret.Data == nil || refresh { + if secret.Data == nil || shouldRenew { keyPair, err := createKeyPairFn() if err != nil { - return nil, false, err + return nil, err + } + if err := r.patchSecret(ctx, secretType, &secret, keyPair, opts.relatedObject); err != nil { + return nil, err } - if err := r.patchSecret(ctx, &secret, keyPair); err != nil { - return nil, false, err + return keyPair, nil + } + + keyPairOpts := opts.KeyPairOpts() + + if secretType == SecretTypeCA { + keyPair, err = pki.NewKeyPairFromCASecret(&secret, keyPairOpts...) + if err != nil { + return nil, err + } + } else { + keyPair, err = pki.NewKeyPairFromTLSSecret(&secret, keyPairOpts...) + if err != nil { + return nil, err } - return keyPair, true, nil } - keyPair, err = pki.KeyPairFromTLSSecret(&secret) + return keyPair, nil +} + +func (r *CertReconciler) getCAKeyPair(ctx context.Context, opts *CertReconcilerOpts) (*pki.KeyPair, error) { + var secret corev1.Secret + if err := r.Get(ctx, opts.caSecretKey, &secret); err != nil { + return nil, fmt.Errorf("error getting CA keypair Secret: %w", err) + } + keyPairOpts := opts.KeyPairOpts() + + if opts.caSecretType == SecretTypeCA { + keyPair, err := pki.NewKeyPairFromCASecret(&secret, keyPairOpts...) + return r.handleCAKeyPairResult(keyPair, err, opts.caSecretKey.Name, opts) + } + + keyPair, err := pki.NewKeyPairFromTLSSecret(&secret, keyPairOpts...) + return r.handleCAKeyPairResult(keyPair, err, opts.caSecretKey.Name, opts) +} + +func (r *CertReconciler) handleCAKeyPairResult(keyPair *pki.KeyPair, err error, secretName string, + opts *CertReconcilerOpts) (*pki.KeyPair, error) { if err != nil { - return nil, false, err + if errors.Is(err, pki.ErrSecretKeyNotFound) { + msg := fmt.Sprintf("key not found in CA Secret \"%s\": %v", secretName, err) + + if relatedObj := opts.relatedObject; relatedObj != nil { + r.recorder.Event(opts.relatedObject, corev1.EventTypeWarning, mariadbv1alpha1.SecretKeyNotFound, msg) + } + return nil, errors.New(msg) + } + return nil, fmt.Errorf("error getting CA Secret \"%s\": %v", secretName, err) } - return keyPair, false, nil + return keyPair, nil } -func (r *CertReconciler) createCA() (*pki.KeyPair, error) { - return pki.CreateCA( - pki.WithCommonName(r.caCommonName), - pki.WithNotBefore(time.Now().Add(-1*time.Hour)), - pki.WithNotAfter(time.Now().Add(r.caValidity)), - ) +func (r *CertReconciler) createCAFn(opts *CertReconcilerOpts) func() (*pki.KeyPair, error) { + return func() (*pki.KeyPair, error) { + x509Opts, err := opts.CAx509Opts() + if err != nil { + return nil, fmt.Errorf("error getting CA x509 opts: %v", err) + } + return pki.CreateCA(x509Opts...) + } } -func (r *CertReconciler) createCertFn(caKeyPair *pki.KeyPair) func() (*pki.KeyPair, error) { +func (r *CertReconciler) createCertFn(caKeyPair *pki.KeyPair, opts *CertReconcilerOpts) func() (*pki.KeyPair, error) { return func() (*pki.KeyPair, error) { - return pki.CreateCert( - caKeyPair, - pki.WithCommonName(r.certCommonName), - pki.WithDNSNames(r.certDNSNames), - pki.WithNotBefore(time.Now().Add(-1*time.Hour)), - pki.WithNotAfter(time.Now().Add(r.certValidity)), - ) + x509Opts, err := opts.Certx509Opts() + if err != nil { + return nil, fmt.Errorf("errors getting certificate x509 opts: %v", err) + } + return pki.CreateCert(caKeyPair, x509Opts...) } } -func (r *CertReconciler) createSecret(ctx context.Context, key types.NamespacedName, secret *corev1.Secret, keyPair *pki.KeyPair) error { +func (r *CertReconciler) createSecret(ctx context.Context, key types.NamespacedName, secretType SecretType, secret *corev1.Secret, + keyPair *pki.KeyPair, owner metav1.Object) error { secret.ObjectMeta = metav1.ObjectMeta{ Name: key.Name, Namespace: key.Namespace, } - secret.Type = corev1.SecretTypeTLS - keyPair.FillTLSSecret(secret) + + if secretType == SecretTypeCA { + keyPair.UpdateCASecret(secret) + } else { + secret.Type = corev1.SecretTypeTLS + keyPair.UpdateTLSSecret(secret) + } + if err := r.updateSecretMetadata(secret, owner); err != nil { + return fmt.Errorf("error updating Secret metadata: %v", err) + } + if err := r.Create(ctx, secret); err != nil { return fmt.Errorf("Error creating TLS Secret: %v", err) } return nil } -func (r *CertReconciler) patchSecret(ctx context.Context, secret *corev1.Secret, keyPair *pki.KeyPair) error { +func (r *CertReconciler) patchSecret(ctx context.Context, secretType SecretType, secret *corev1.Secret, + keyPair *pki.KeyPair, owner metav1.Object) error { patch := client.MergeFrom(secret.DeepCopy()) - keyPair.FillTLSSecret(secret) + + if secretType == SecretTypeCA { + keyPair.UpdateCASecret(secret) + } else { + secret.Type = corev1.SecretTypeTLS + keyPair.UpdateTLSSecret(secret) + } + if err := r.updateSecretMetadata(secret, owner); err != nil { + return fmt.Errorf("error updating Secret metadata: %v", err) + } + if err := r.Patch(ctx, secret, patch); err != nil { return fmt.Errorf("Error patching TLS Secret: %v", err) } return nil } -func (r *CertReconciler) lookaheadTime() time.Time { - return time.Now().Add(r.lookaheadValidity) +func (r *CertReconciler) updateSecretMetadata(secret *corev1.Secret, owner metav1.Object) error { + if secret.Labels == nil { + secret.Labels = make(map[string]string) + } + secret.Labels[metadata.WatchLabel] = "" + + if owner != nil { + if err := controllerutil.SetControllerReference(owner, secret, r.scheme); err != nil { + return fmt.Errorf("error setting controller reference to Secret: %v", err) + } + } + return nil +} + +func (r *CertReconciler) getCABundle(ctx context.Context, caKeyPair *pki.KeyPair, opts *CertReconcilerOpts, + logger logr.Logger) ([]*x509.Certificate, error) { + if opts.caBundleSecretKey != nil && opts.caBundleNamespace != nil { + bundle, err := r.refResolver.SecretKeyRef(ctx, *opts.caBundleSecretKey, *opts.caBundleNamespace) + if err == nil { + certs, err := pki.ParseCertificates([]byte(bundle)) + if err != nil { + return nil, fmt.Errorf("error parsing bundle certificates: %v", err) + } + return certs, nil + } else { + logger.V(1).Info("error getting CA bundle", "err", err) + } + } + + if caKeyPair != nil { + caCerts, err := caKeyPair.Certificates() + if err != nil { + return nil, fmt.Errorf("error getting CA certificates: %v", err) + } + return caCerts, nil + } + + return nil, errors.New("unable to get CA bundle") } diff --git a/pkg/controller/certificate/options.go b/pkg/controller/certificate/options.go new file mode 100644 index 0000000000..a905d37419 --- /dev/null +++ b/pkg/controller/certificate/options.go @@ -0,0 +1,197 @@ +package certificate + +import ( + "crypto/x509" + "errors" + "time" + + cmmeta "github.com/cert-manager/cert-manager/pkg/apis/meta/v1" + mariadbv1alpha1 "github.com/mariadb-operator/mariadb-operator/api/v1alpha1" + "github.com/mariadb-operator/mariadb-operator/pkg/pki" + "k8s.io/apimachinery/pkg/types" +) + +type CertReconcilerOpts struct { + caBundleSecretKey *mariadbv1alpha1.SecretKeySelector + caBundleNamespace *string + + shouldIssueCA bool + caSecretKey types.NamespacedName + caSecretType SecretType + caCommonName string + caLifetime time.Duration + + shouldIssueCert bool + certHandler CertHandler + certIssuerRef *cmmeta.ObjectReference + certSecretKey types.NamespacedName + certCommonName string + certDNSNames []string + certLifetime time.Duration + certKeyUsage x509.KeyUsage + certExtKeyUsage []x509.ExtKeyUsage + + supportedPrivateKeys []pki.PrivateKey + + renewBeforePercentage int32 + + relatedObject RelatedObject +} + +type CertReconcilerOpt func(*CertReconcilerOpts) + +func WithCABundle(secretKey mariadbv1alpha1.SecretKeySelector, namespace string) CertReconcilerOpt { + return func(o *CertReconcilerOpts) { + o.caBundleSecretKey = &secretKey + o.caBundleNamespace = &namespace + } +} + +func WithCA(shouldIssue bool, secretKey types.NamespacedName) CertReconcilerOpt { + return func(o *CertReconcilerOpts) { + o.shouldIssueCA = shouldIssue + o.caSecretKey = secretKey + o.caCommonName = secretKey.Name + } +} + +func WithCACommonName(commonName string) CertReconcilerOpt { + return func(o *CertReconcilerOpts) { + o.caCommonName = commonName + } +} + +func WithCALifetime(lifetime time.Duration) CertReconcilerOpt { + return func(o *CertReconcilerOpts) { + o.caLifetime = lifetime + } +} + +func WithCASecretType(secretType SecretType) CertReconcilerOpt { + return func(o *CertReconcilerOpts) { + o.caSecretType = secretType + } +} + +func WithCert(shouldIssue bool, secretKey types.NamespacedName, dnsNames []string) CertReconcilerOpt { + return func(o *CertReconcilerOpts) { + o.shouldIssueCert = shouldIssue + o.certSecretKey = secretKey + if len(dnsNames) > 0 { + o.certCommonName = dnsNames[0] + } + o.certDNSNames = dnsNames + } +} + +func WithCertHandler(certHandler CertHandler) CertReconcilerOpt { + return func(o *CertReconcilerOpts) { + o.certHandler = certHandler + } +} + +func WithCertIssuerRef(issuerRef *cmmeta.ObjectReference) CertReconcilerOpt { + return func(o *CertReconcilerOpts) { + o.certIssuerRef = issuerRef + } +} + +func WithCertLifetime(lifetime time.Duration) CertReconcilerOpt { + return func(o *CertReconcilerOpts) { + o.certLifetime = lifetime + } +} + +func WithCertKeyUsage(keyUsage x509.KeyUsage) CertReconcilerOpt { + return func(o *CertReconcilerOpts) { + o.certKeyUsage = keyUsage + } +} + +func WithCertExtKeyUsage(extKeyUsage ...x509.ExtKeyUsage) CertReconcilerOpt { + return func(o *CertReconcilerOpts) { + o.certExtKeyUsage = extKeyUsage + } +} + +func WithServerCertKeyUsage() CertReconcilerOpt { + return func(o *CertReconcilerOpts) { + o.certKeyUsage = x509.KeyUsageKeyEncipherment + o.certExtKeyUsage = []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth} + } +} + +func WithClientCertKeyUsage() CertReconcilerOpt { + return func(o *CertReconcilerOpts) { + o.certExtKeyUsage = []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth} + } +} + +func WithSupportedPrivateKeys(privateKeys ...pki.PrivateKey) CertReconcilerOpt { + return func(o *CertReconcilerOpts) { + o.supportedPrivateKeys = privateKeys + } +} + +func WithRenewBeforePercentage(percentage int32) CertReconcilerOpt { + return func(o *CertReconcilerOpts) { + o.renewBeforePercentage = percentage + } +} + +func WithRelatedObject(obj RelatedObject) CertReconcilerOpt { + return func(o *CertReconcilerOpts) { + o.relatedObject = obj + } +} + +func (o *CertReconcilerOpts) CAx509Opts() ([]pki.X509Opt, error) { + if o.caCommonName == "" || o.caLifetime == 0 { + return nil, errors.New("caCommonName and caValidity must be set") + } + + return []pki.X509Opt{ + pki.WithCommonName(o.caCommonName), + pki.WithNotBefore(time.Now().Add(-1 * time.Hour)), + pki.WithNotAfter(time.Now().Add(o.caLifetime)), + pki.WithKeyPairOpts(o.KeyPairOpts()...), + }, nil +} + +func (o *CertReconcilerOpts) Certx509Opts() ([]pki.X509Opt, error) { + if len(o.certDNSNames) == 0 || o.certLifetime == 0 { + return nil, errors.New("certDNSNames and certLifetime must be set") + } + + return []pki.X509Opt{ + pki.WithCommonName(o.certCommonName), + pki.WithDNSNames(o.certDNSNames...), + pki.WithNotBefore(time.Now().Add(-1 * time.Hour)), + pki.WithNotAfter(time.Now().Add(o.certLifetime)), + pki.WithKeyUsage(o.certKeyUsage), + pki.WithExtKeyUsage(o.certExtKeyUsage...), + pki.WithKeyPairOpts(o.KeyPairOpts()...), + }, nil +} + +func (o *CertReconcilerOpts) KeyPairOpts() []pki.KeyPairOpt { + return []pki.KeyPairOpt{ + pki.WithSupportedPrivateKeys(o.supportedPrivateKeys...), + } +} + +func NewDefaultCertificateOpts() *CertReconcilerOpts { + opts := &CertReconcilerOpts{ + shouldIssueCA: true, + caSecretType: SecretTypeCA, + caLifetime: pki.DefaultCALifetime, + shouldIssueCert: true, + certHandler: &DefaultCertHandler{}, + certLifetime: pki.DefaultCertLifetime, + supportedPrivateKeys: []pki.PrivateKey{ + pki.PrivateKeyTypeECDSA, + }, + renewBeforePercentage: pki.DefaultRenewBeforePercentage, + } + return opts +} diff --git a/pkg/controller/certificate/types.go b/pkg/controller/certificate/types.go new file mode 100644 index 0000000000..9ba2d37a19 --- /dev/null +++ b/pkg/controller/certificate/types.go @@ -0,0 +1,37 @@ +package certificate + +import ( + "context" + + "github.com/mariadb-operator/mariadb-operator/pkg/pki" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" +) + +type SecretType int + +const ( + SecretTypeCA SecretType = iota + SecretTypeTLS +) + +type CertHandler interface { + ShouldRenewCert(ctx context.Context, caKeyPair *pki.KeyPair) (shouldRenew bool, reason string, err error) + HandleExpiredCert(ctx context.Context) error +} + +type DefaultCertHandler struct{} + +func (h *DefaultCertHandler) ShouldRenewCert(ctx context.Context, caKeyPair *pki.KeyPair) (shouldRenew bool, reason string, err error) { + return true, "Certificate lifetime within renewal window", nil +} + +func (h *DefaultCertHandler) HandleExpiredCert(ctx context.Context) error { + // noop + return nil +} + +type RelatedObject interface { + runtime.Object + metav1.Object +} diff --git a/pkg/controller/galera/clientset.go b/pkg/controller/galera/clientset.go index 33eb250387..6e646b7c45 100644 --- a/pkg/controller/galera/clientset.go +++ b/pkg/controller/galera/clientset.go @@ -58,8 +58,13 @@ func (c *agentClientSet) validateIndex(index int) error { } func baseUrl(mariadb *mariadbv1alpha1.MariaDB, index int) string { + scheme := "http" + if mariadb.IsTLSEnabled() { + scheme = "https" + } return fmt.Sprintf( - "http://%s:%d", + "%s://%s:%d", + scheme, statefulset.PodFQDNWithService( mariadb.ObjectMeta, index, diff --git a/pkg/controller/galera/controller.go b/pkg/controller/galera/controller.go index 9ecd606020..65637b76bd 100644 --- a/pkg/controller/galera/controller.go +++ b/pkg/controller/galera/controller.go @@ -14,6 +14,7 @@ import ( "github.com/mariadb-operator/mariadb-operator/pkg/environment" "github.com/mariadb-operator/mariadb-operator/pkg/galera/errors" mdbhttp "github.com/mariadb-operator/mariadb-operator/pkg/http" + "github.com/mariadb-operator/mariadb-operator/pkg/pki" "github.com/mariadb-operator/mariadb-operator/pkg/refresolver" appsv1 "k8s.io/api/apps/v1" corev1 "k8s.io/api/core/v1" @@ -182,6 +183,42 @@ func (r *GaleraReconciler) newAgentClientSet(ctx context.Context, mariadb *maria ) } + if mariadb.IsTLSEnabled() { + tlsCA, err := r.refResolver.SecretKeyRef(ctx, mariadb.TLSCABundleSecretKeyRef(), mariadb.Namespace) + if err != nil { + return nil, fmt.Errorf("error reading TLS CA bundle: %v", err) + } + + clientCertKeySelector := mariadbv1alpha1.SecretKeySelector{ + LocalObjectReference: mariadbv1alpha1.LocalObjectReference{ + Name: mariadb.TLSClientCertSecretKey().Name, + }, + Key: pki.TLSCertKey, + } + tlsCert, err := r.refResolver.SecretKeyRef(ctx, clientCertKeySelector, mariadb.Namespace) + if err != nil { + return nil, fmt.Errorf("error reading TLS cert: %v", err) + } + + clientKeyKeySelector := mariadbv1alpha1.SecretKeySelector{ + LocalObjectReference: mariadbv1alpha1.LocalObjectReference{ + Name: mariadb.TLSClientCertSecretKey().Name, + }, + Key: pki.TLSKeyKey, + } + tlsKey, err := r.refResolver.SecretKeyRef(ctx, clientKeyKeySelector, mariadb.Namespace) + if err != nil { + return nil, fmt.Errorf("error reading TLS key: %v", err) + } + + opts = append(opts, []mdbhttp.Option{ + mdbhttp.WithTLSEnabled(mariadb.IsTLSEnabled()), + mdbhttp.WithTLSCA([]byte(tlsCA)), + mdbhttp.WithTLSCert([]byte(tlsCert)), + mdbhttp.WithTLSKey([]byte(tlsKey)), + }...) + } + return newAgentClientSet(mariadb, opts...) } diff --git a/pkg/controller/galera/recovery.go b/pkg/controller/galera/recovery.go index 635393e0bc..c22f08ad44 100644 --- a/pkg/controller/galera/recovery.go +++ b/pkg/controller/galera/recovery.go @@ -278,7 +278,7 @@ func (r *GaleraReconciler) getGaleraState(ctx context.Context, mariadb *mariadbv defer cancelRecovery() err = wait.PollWithMariaDB(recoveryCtx, mariadbKey, r.Client, stateLogger, func(ctx context.Context) error { - if err := r.ensurePodRunning(ctx, mariadbKey, ctrlclient.ObjectKeyFromObject(&pod), logger); err != nil { + if err := r.ensurePodHealthy(ctx, mariadbKey, ctrlclient.ObjectKeyFromObject(&pod), clientSet, logger); err != nil { return err } galeraState, err := client.Galera.GetState(ctx) @@ -429,7 +429,7 @@ func (r *GaleraReconciler) enableBootstrapWithSource(ctx context.Context, mariad } if err = wait.PollWithMariaDB(ctx, mariadbKey, r.Client, logger, func(ctx context.Context) error { - if err := r.ensurePodRunning(ctx, mariadbKey, podKey, logger); err != nil { + if err := r.ensurePodHealthy(ctx, mariadbKey, podKey, clientSet, logger); err != nil { return err } return client.Galera.EnableBootstrap(ctx, src.bootstrap) @@ -451,7 +451,7 @@ func (r *GaleraReconciler) disableBootstrapInPod(ctx context.Context, mariadbKey } if err = wait.PollWithMariaDB(ctx, mariadbKey, r.Client, logger, func(ctx context.Context) error { - if err := r.ensurePodRunning(ctx, mariadbKey, podKey, logger); err != nil { + if err := r.ensurePodHealthy(ctx, mariadbKey, podKey, clientSet, logger); err != nil { return err } if err := client.Galera.DisableBootstrap(ctx); err != nil && !galeraerrors.IsNotFound(err) { @@ -489,16 +489,17 @@ func (r *GaleraReconciler) patchStatefulSetReplicas(ctx context.Context, key typ }) } -func (r *GaleraReconciler) ensurePodRunning(ctx context.Context, mariadbKey, podKey types.NamespacedName, logger logr.Logger) error { +func (r *GaleraReconciler) ensurePodHealthy(ctx context.Context, mariadbKey, podKey types.NamespacedName, clientSet *agentClientSet, + logger logr.Logger) error { initialCtx, initialCancel := context.WithTimeout(ctx, 30*time.Second) defer initialCancel() - if err := r.pollUntilPodRunning(initialCtx, mariadbKey, podKey, logger); err != nil { + if err := r.pollUntilPodHealthy(initialCtx, mariadbKey, podKey, clientSet, logger); err != nil { logger.V(1).Info("Initial wait for Pod timed out", "pod", podKey.Name, "err", err) } else { return nil } - logger.V(1).Info("Pod not running. Recreating...", "pod", podKey.Name) + logger.V(1).Info("Pod not healthy. Recreating...", "pod", podKey.Name) var pod corev1.Pod if err := r.Get(ctx, podKey, &pod); err != nil { return fmt.Errorf("error getting Pod '%s': %v", podKey.Name, err) @@ -506,7 +507,7 @@ func (r *GaleraReconciler) ensurePodRunning(ctx context.Context, mariadbKey, pod if err := r.Delete(ctx, &pod); err != nil { return fmt.Errorf("error deleting Pod '%s': %v", podKey.Name, err) } - return r.pollUntilPodRunning(ctx, mariadbKey, podKey, logger) + return r.pollUntilPodHealthy(ctx, mariadbKey, podKey, clientSet, logger) } func (r *GaleraReconciler) ensureJob(ctx context.Context, recoveryJob *batchv1.Job) error { @@ -520,16 +521,34 @@ func (r *GaleraReconciler) ensureJob(ctx context.Context, recoveryJob *batchv1.J return nil } -func (r *GaleraReconciler) pollUntilPodRunning(ctx context.Context, mariadbKey, podKey types.NamespacedName, logger logr.Logger) error { +func (r *GaleraReconciler) pollUntilPodHealthy(ctx context.Context, mariadbKey, podKey types.NamespacedName, clientSet *agentClientSet, + logger logr.Logger) error { + i, err := statefulset.PodIndex(podKey.Name) + if err != nil { + return fmt.Errorf("error getting index for Pod '%s': %v", podKey.Name, err) + } + client, err := clientSet.clientForIndex(*i) + if err != nil { + return fmt.Errorf("error getting client for Pod '%s': %v", podKey.Name, err) + } + return wait.PollWithMariaDB(ctx, mariadbKey, r.Client, logger, func(ctx context.Context) error { var pod corev1.Pod if err := r.Get(ctx, podKey, &pod); err != nil { return fmt.Errorf("error getting Pod '%s': %v", podKey.Name, err) } - if pod.Status.Phase == corev1.PodRunning { - return nil + if pod.Status.Phase != corev1.PodRunning { + return errors.New("Pod not running") + } + + healthy, err := client.Galera.Health(ctx) + if err != nil { + return fmt.Errorf("error getting Galera health: %v", err) + } + if !healthy { + return errors.New("Galera not healthy") } - return errors.New("Pod not running") + return nil }) } diff --git a/pkg/controller/galera/resources/resources.go b/pkg/controller/galera/resources/resources.go index 988a8c5f3c..e6ec454fae 100644 --- a/pkg/controller/galera/resources/resources.go +++ b/pkg/controller/galera/resources/resources.go @@ -14,4 +14,5 @@ var ( GaleraISTPortName = "ist" GaleraISTPort = int32(4568) AgentPortName = "agent" + AgentProbePortName = "agent-probe" ) diff --git a/pkg/controller/replication/config.go b/pkg/controller/replication/config.go index e7a51b46a1..0e0b9825e1 100644 --- a/pkg/controller/replication/config.go +++ b/pkg/controller/replication/config.go @@ -6,12 +6,17 @@ import ( mariadbv1alpha1 "github.com/mariadb-operator/mariadb-operator/api/v1alpha1" "github.com/mariadb-operator/mariadb-operator/pkg/builder" + builderpki "github.com/mariadb-operator/mariadb-operator/pkg/builder/pki" "github.com/mariadb-operator/mariadb-operator/pkg/controller/secret" + env "github.com/mariadb-operator/mariadb-operator/pkg/environment" "github.com/mariadb-operator/mariadb-operator/pkg/refresolver" + "github.com/mariadb-operator/mariadb-operator/pkg/sql" sqlClient "github.com/mariadb-operator/mariadb-operator/pkg/sql" "github.com/mariadb-operator/mariadb-operator/pkg/statefulset" + "github.com/mariadb-operator/mariadb-operator/pkg/version" "k8s.io/apimachinery/pkg/types" "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/log" ) var ( @@ -25,14 +30,17 @@ type ReplicationConfig struct { builder *builder.Builder refResolver *refresolver.RefResolver secretReconciler *secret.SecretReconciler + env *env.OperatorEnv } -func NewReplicationConfig(client client.Client, builder *builder.Builder, secretReconciler *secret.SecretReconciler) *ReplicationConfig { +func NewReplicationConfig(client client.Client, builder *builder.Builder, secretReconciler *secret.SecretReconciler, + env *env.OperatorEnv) *ReplicationConfig { return &ReplicationConfig{ Client: client, builder: builder, refResolver: refresolver.New(client), secretReconciler: secretReconciler, + env: env, } } @@ -143,24 +151,71 @@ func (r *ReplicationConfig) changeMaster(ctx context.Context, mariadb *mariadbv1 return fmt.Errorf("error getting GTID: %v", err) } - changeMasterOpts := &sqlClient.ChangeMasterOpts{ - Connection: connectionName, + changeMasterHostOpt, err := r.getChangeMasterHost(ctx, mariadb, primaryPodIndex) + if err != nil { + return fmt.Errorf("error getting host option: %v", err) + } + + changeMasterOpts := []sql.ChangeMasterOpt{ + changeMasterHostOpt, + sql.WithChangeMasterConnection(connectionName), + sql.WithChangeMasterPort(mariadb.Spec.Port), + sql.WithChangeMasterCredentials(replUser, password), + sql.WithChangeMasterGtid(gtidString), + sql.WithChangeMasterRetries(*mariadb.Replication().Replica.ConnectionRetries), + } + if mariadb.IsTLSEnabled() { + changeMasterOpts = append(changeMasterOpts, sql.WithChangeMasterSSL( + builderpki.ClientCertPath, + builderpki.ClientKeyPath, + builderpki.CACertPath, + )) + } + if err := client.ChangeMaster(ctx, changeMasterOpts...); err != nil { + return fmt.Errorf("error changing master: %v", err) + } + return nil +} + +func (r *ReplicationConfig) getChangeMasterHost(ctx context.Context, mariadb *mariadbv1alpha1.MariaDB, + primaryPodIndex int) (sql.ChangeMasterOpt, error) { + logger := log.FromContext(ctx). + WithName("replication-config"). + WithValues("image", mariadb.Spec.Image). + V(1) + vOpts := []version.Option{ + version.WithLogger(logger), + } + if r.env != nil && r.env.MariadbDefaultVersion != "" { + vOpts = append(vOpts, version.WithDefaultVersion(r.env.MariadbDefaultVersion)) + } + v, err := version.NewVersion(mariadb.Spec.Image, vOpts...) + if err != nil { + return nil, fmt.Errorf("error creating version: %v", err) + } + + isCompatibleVersion, err := v.GreaterThanOrEqual("10.6") + if err != nil { + return nil, fmt.Errorf("error comparing version: %v", err) + } + + if isCompatibleVersion { + return sql.WithChangeMasterHost( + statefulset.PodFQDNWithService( + mariadb.ObjectMeta, + primaryPodIndex, + mariadb.InternalServiceKey().Name, + ), + ), nil + } + return sql.WithChangeMasterHost( // MariaDB 10.5 has a limitation of 60 characters in this host. - Host: statefulset.PodShortFQDNWithService( + statefulset.PodShortFQDNWithService( mariadb.ObjectMeta, primaryPodIndex, mariadb.InternalServiceKey().Name, ), - User: replUser, - Port: mariadb.Spec.Port, - Password: password, - Gtid: gtidString, - Retries: *mariadb.Replication().Replica.ConnectionRetries, - } - if err := client.ChangeMaster(ctx, changeMasterOpts); err != nil { - return fmt.Errorf("error changing master: %v", err) - } - return nil + ), nil } func (r *ReplicationConfig) reconcilePrimarySql(ctx context.Context, mariadb *mariadbv1alpha1.MariaDB, client *sqlClient.Client) error { diff --git a/pkg/datastructures/datastructures.go b/pkg/datastructures/datastructures.go index 563785af48..7c4659065e 100644 --- a/pkg/datastructures/datastructures.go +++ b/pkg/datastructures/datastructures.go @@ -45,6 +45,10 @@ func AllExists[T any](idx Index[T], keys ...string) bool { return true } +func Has[T any](idx Index[T], key string) bool { + return AllExists(idx, key) +} + func Filter[T any](idx Index[T], keys ...string) Index[T] { filterIdx := NewIndex[string](keys, func(s string) string { return s diff --git a/pkg/datastructures/datastructures_test.go b/pkg/datastructures/datastructures_test.go index 0b8d01addf..ac3dcaa4cd 100644 --- a/pkg/datastructures/datastructures_test.go +++ b/pkg/datastructures/datastructures_test.go @@ -44,6 +44,18 @@ func TestDataStructures(t *testing.T) { t.Errorf("expecting exists to be %v, got: %v", expectedExists, exists) } + exists = Has(idx, "a") + expectedExists = true + if exists != expectedExists { + t.Errorf("expecting exists to be %v, got: %v", expectedExists, exists) + } + + exists = Has(idx, "z") + expectedExists = false + if exists != expectedExists { + t.Errorf("expecting exists to be %v, got: %v", expectedExists, exists) + } + filteredIdx := Filter(idx, "a", "b", "c") expectedFilteredIdx := newIndex("a", "b", "c") if !reflect.DeepEqual(filteredIdx, expectedFilteredIdx) { diff --git a/pkg/discovery/discovery.go b/pkg/discovery/discovery.go index ebfc4661b4..7fbe09cca7 100644 --- a/pkg/discovery/discovery.go +++ b/pkg/discovery/discovery.go @@ -32,6 +32,8 @@ func WithEnterprise(isEnterprise bool) DiscoveryOpt { } } +type NewDiscoveryFn func(opts ...DiscoveryOpt) (*Discovery, error) + func NewDiscovery(opts ...DiscoveryOpt) (*Discovery, error) { discovery := Discovery{} for _, setOpt := range opts { @@ -51,10 +53,12 @@ func NewDiscovery(opts ...DiscoveryOpt) (*Discovery, error) { return &discovery, nil } -func NewDiscoveryEnterprise() (*Discovery, error) { - return NewDiscovery( +func NewDiscoveryEnterprise(opts ...DiscoveryOpt) (*Discovery, error) { + discoveryOpts := []DiscoveryOpt{ WithEnterprise(true), - ) + } + discoveryOpts = append(discoveryOpts, opts...) + return NewDiscovery(discoveryOpts...) } func NewFakeDiscovery(isEnterprise bool, resources ...*metav1.APIResourceList) (*Discovery, error) { @@ -80,6 +84,10 @@ func (c *Discovery) ServiceMonitorExist() (bool, error) { return c.resourceExist("monitoring.coreos.com/v1", "servicemonitors") } +func (c *Discovery) CertificateExist() (bool, error) { + return c.resourceExist("cert-manager.io/v1", "certificates") +} + func (c *Discovery) SecurityContextConstrainstsExist() (bool, error) { return c.resourceExist("security.openshift.io/v1", "securitycontextconstraints") } diff --git a/pkg/discovery/discovery_test.go b/pkg/discovery/discovery_test.go index 5877cc502f..bebbc2d936 100644 --- a/pkg/discovery/discovery_test.go +++ b/pkg/discovery/discovery_test.go @@ -16,6 +16,16 @@ func TestDiscoveryServiceMonitors(t *testing.T) { }) } +func TestDiscoveryCertificates(t *testing.T) { + testDiscoveryResource(t, + "Certificates", + "cert-manager.io/v1", + "certificates", + func(d *Discovery) (bool, error) { + return d.CertificateExist() + }) +} + func TestDiscoverySecurityContextConstraints(t *testing.T) { testDiscoveryResource(t, "SecurityContextConstraints", diff --git a/pkg/embed/embed.go b/pkg/embed/embed.go index 79fc574b3d..6ef0f86775 100644 --- a/pkg/embed/embed.go +++ b/pkg/embed/embed.go @@ -19,29 +19,33 @@ import ( var fs embed.FS func ReadEntrypoint(ctx context.Context, mariadb *mariadbv1alpha1.MariaDB, operatorEnv *env.OperatorEnv) ([]byte, error) { - var minorVersion string - var err error image := mariadb.Spec.Image logger := log.FromContext(ctx). WithName("entrypoint"). WithValues("image", image). V(1) - minorVersion, err = version.GetMinorVersion(image) + vOpts := []version.Option{ + version.WithLogger(logger), + } + if operatorEnv != nil && operatorEnv.MariadbDefaultVersion != "" { + vOpts = append(vOpts, version.WithDefaultVersion(operatorEnv.MariadbDefaultVersion)) + } + + version, err := version.NewVersion(image, vOpts...) + if err != nil { + return nil, fmt.Errorf("error parsing version: %v", err) + } + minorVersion, err := version.GetMinorVersion() if err != nil { - logger.Info( - "error getting entrypoint version. Using default version", - "version", operatorEnv.MariadbEntrypointVersion, - "err", err, - ) - minorVersion = operatorEnv.MariadbEntrypointVersion + return nil, fmt.Errorf("error getting minor version: %v", err) } logger = logger.WithValues("version", minorVersion) bytes, err := readEntrypoint(minorVersion, logger) if err != nil { if errors.Is(err, iofs.ErrNotExist) { - bytes, err := readEntrypoint(operatorEnv.MariadbEntrypointVersion, logger) + bytes, err := readEntrypoint(operatorEnv.MariadbDefaultVersion, logger) if err != nil { return nil, fmt.Errorf("error reading MariaDB default entrypoint: %v", err) } diff --git a/pkg/embed/embed_test.go b/pkg/embed/embed_test.go index 8a39aba192..3ede31fd59 100644 --- a/pkg/embed/embed_test.go +++ b/pkg/embed/embed_test.go @@ -23,6 +23,15 @@ func TestReadEntrypoint(t *testing.T) { wantBytes: false, wantErr: true, }, + { + name: "empty with default", + mariadb: &mariadbv1alpha1.MariaDB{}, + env: &environment.OperatorEnv{ + MariadbDefaultVersion: "10.11", + }, + wantBytes: true, + wantErr: false, + }, { name: "invalid version", mariadb: &mariadbv1alpha1.MariaDB{ @@ -35,14 +44,38 @@ func TestReadEntrypoint(t *testing.T) { wantErr: true, }, { - name: "default invalid version", + name: "invalid version with default", mariadb: &mariadbv1alpha1.MariaDB{ Spec: mariadbv1alpha1.MariaDBSpec{ Image: "mariadb:foo", }, }, env: &environment.OperatorEnv{ - MariadbEntrypointVersion: "10.11", + MariadbDefaultVersion: "10.11", + }, + wantBytes: true, + wantErr: false, + }, + { + name: "sha256", + mariadb: &mariadbv1alpha1.MariaDB{ + Spec: mariadbv1alpha1.MariaDBSpec{ + Image: "mariadb@sha256:3f48454b6a33e094af6d23ced54645ec0533cb11854d07738920852ca48e390d", + }, + }, + env: &environment.OperatorEnv{}, + wantBytes: false, + wantErr: true, + }, + { + name: "sha256 with default", + mariadb: &mariadbv1alpha1.MariaDB{ + Spec: mariadbv1alpha1.MariaDBSpec{ + Image: "mariadb@sha256:3f48454b6a33e094af6d23ced54645ec0533cb11854d07738920852ca48e390d", + }, + }, + env: &environment.OperatorEnv{ + MariadbDefaultVersion: "10.11", }, wantBytes: true, wantErr: false, @@ -59,14 +92,14 @@ func TestReadEntrypoint(t *testing.T) { wantErr: true, }, { - name: "default unsupported version", + name: "unsupported version with default", mariadb: &mariadbv1alpha1.MariaDB{ Spec: mariadbv1alpha1.MariaDBSpec{ Image: "mariadb:8.0.0", }, }, env: &environment.OperatorEnv{ - MariadbEntrypointVersion: "10.11", + MariadbDefaultVersion: "10.11", }, wantBytes: true, wantErr: false, @@ -91,6 +124,19 @@ func TestReadEntrypoint(t *testing.T) { wantBytes: true, wantErr: false, }, + { + name: "invalid default", + mariadb: &mariadbv1alpha1.MariaDB{ + Spec: mariadbv1alpha1.MariaDBSpec{ + Image: "", + }, + }, + env: &environment.OperatorEnv{ + MariadbDefaultVersion: "latest", + }, + wantBytes: false, + wantErr: true, + }, } for _, tt := range tests { diff --git a/pkg/environment/environment.go b/pkg/environment/environment.go index 9bc2cae7f7..ead4471ef1 100644 --- a/pkg/environment/environment.go +++ b/pkg/environment/environment.go @@ -20,7 +20,7 @@ type OperatorEnv struct { RelatedExporterImage string `env:"RELATED_IMAGE_EXPORTER,required"` RelatedExporterMaxscaleImage string `env:"RELATED_IMAGE_EXPORTER_MAXSCALE,required"` MariadbGaleraLibPath string `env:"MARIADB_GALERA_LIB_PATH,required"` - MariadbEntrypointVersion string `env:"MARIADB_ENTRYPOINT_VERSION,required"` + MariadbDefaultVersion string `env:"MARIADB_DEFAULT_VERSION,required"` WatchNamespace string `env:"WATCH_NAMESPACE"` } @@ -65,6 +65,12 @@ type PodEnvironment struct { MariadbName string `env:"MARIADB_NAME,required"` MariadbRootPassword string `env:"MARIADB_ROOT_PASSWORD,required"` MariadbPort string `env:"MYSQL_TCP_PORT,required"` + TLSEnabled string `env:"TLS_ENABLED"` + TLSCACertPath string `env:"TLS_CA_CERT_PATH"` + TLSServerCertPath string `env:"TLS_SERVER_CERT_PATH"` + TLSServerKeyPath string `env:"TLS_SERVER_KEY_PATH"` + TLSClientCertPath string `env:"TLS_CLIENT_CERT_PATH"` + TLSClientKeyPath string `env:"TLS_CLIENT_KEY_PATH"` } func (e *PodEnvironment) Port() (int32, error) { @@ -75,6 +81,13 @@ func (e *PodEnvironment) Port() (int32, error) { return int32(port), nil } +func (e *PodEnvironment) IsTLSEnabled() (bool, error) { + if e.TLSEnabled == "" { + return false, nil + } + return strconv.ParseBool(e.TLSEnabled) +} + func GetPodEnv(ctx context.Context) (*PodEnvironment, error) { var env PodEnvironment if err := envconfig.Process(ctx, &env); err != nil { diff --git a/pkg/environment/environment_test.go b/pkg/environment/environment_test.go index ce3a887e0e..3f29d40e67 100644 --- a/pkg/environment/environment_test.go +++ b/pkg/environment/environment_test.go @@ -4,9 +4,11 @@ import ( "context" "reflect" "testing" + + "github.com/google/go-cmp/cmp" ) -func TestWathcNamespaces(t *testing.T) { +func TestWatchNamespaces(t *testing.T) { tests := []struct { name string env map[string]string @@ -130,3 +132,82 @@ func TestCurrentNamespaceOnly(t *testing.T) { }) } } + +func TestTLSEnabled(t *testing.T) { + tests := []struct { + name string + env map[string]string + wantBool bool + wantErr bool + }{ + { + name: "no env", + env: map[string]string{}, + wantBool: false, + wantErr: false, + }, + { + name: "empty", + env: map[string]string{ + "TLS_ENABLED": "", + }, + wantBool: false, + wantErr: false, + }, + { + name: "invalid", + env: map[string]string{ + "TLS_ENABLED": "foo", + }, + wantBool: false, + wantErr: true, + }, + { + name: "valid bool", + env: map[string]string{ + "TLS_ENABLED": "true", + }, + wantBool: true, + wantErr: false, + }, + { + name: "valid number", + env: map[string]string{ + "TLS_ENABLED": "1", + }, + wantBool: true, + wantErr: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Setenv("CLUSTER_NAME", "test") + t.Setenv("POD_NAME", "mariadb-0") + t.Setenv("POD_NAMESPACE", "default") + t.Setenv("POD_IP", "10.244.0.11") + t.Setenv("MARIADB_NAME", "mariadb") + t.Setenv("MARIADB_ROOT_PASSWORD", "MariaDB11!") + t.Setenv("MYSQL_TCP_PORT", "3306") + for k, v := range tt.env { + t.Setenv(k, v) + } + + env, err := GetPodEnv(context.Background()) + if err != nil { + t.Fatalf("unexpected error getting environment: %v", err) + } + if env == nil { + return + } + + isTLSEnabled, err := env.IsTLSEnabled() + gotErr := err != nil + if diff := cmp.Diff(tt.wantErr, gotErr); diff != "" { + t.Errorf("unexpected err (-want +got):\n%s", diff) + } + if diff := cmp.Diff(tt.wantBool, isTLSEnabled); diff != "" { + t.Errorf("unexpected bool (-want +got):\n%s", diff) + } + }) + } +} diff --git a/pkg/galera/agent/client/galera.go b/pkg/galera/agent/client/galera.go index 2b69c70f22..273fa08788 100644 --- a/pkg/galera/agent/client/galera.go +++ b/pkg/galera/agent/client/galera.go @@ -18,6 +18,15 @@ func NewGalera(client *mdbhttp.Client) *Galera { } } +func (g *Galera) Health(ctx context.Context) (bool, error) { + res, err := g.client.Get(ctx, "/health", nil) + if err != nil { + return false, err + } + defer res.Body.Close() + return res.StatusCode == http.StatusOK, nil +} + func (g *Galera) GetState(ctx context.Context) (*recovery.GaleraState, error) { res, err := g.client.Get(ctx, "/api/galera/state", nil) if err != nil { diff --git a/pkg/galera/agent/handler/handler.go b/pkg/galera/agent/handler/handler.go deleted file mode 100644 index f1dbc40c92..0000000000 --- a/pkg/galera/agent/handler/handler.go +++ /dev/null @@ -1,43 +0,0 @@ -package handler - -import ( - "sync" - - "github.com/go-logr/logr" - "github.com/mariadb-operator/mariadb-operator/pkg/galera/filemanager" - "github.com/mariadb-operator/mariadb-operator/pkg/galera/state" - mdbhttp "github.com/mariadb-operator/mariadb-operator/pkg/http" - "k8s.io/apimachinery/pkg/types" - ctrlclient "sigs.k8s.io/controller-runtime/pkg/client" -) - -type Handler struct { - Galera *Galera - Probe *Probe -} - -func NewHandler(mariadbKey types.NamespacedName, client ctrlclient.Client, fileManager *filemanager.FileManager, - state *state.State, logger *logr.Logger) *Handler { - mux := &sync.RWMutex{} - galeraLogger := logger.WithName("galera") - probeLogger := logger.WithName("probe") - - galera := NewGalera( - fileManager, - state, - mdbhttp.NewResponseWriter(&galeraLogger), - mux, - &galeraLogger, - ) - probe := NewProbe( - mariadbKey, - client, - mdbhttp.NewResponseWriter(&probeLogger), - &probeLogger, - ) - - return &Handler{ - Galera: galera, - Probe: probe, - } -} diff --git a/pkg/galera/agent/router/router.go b/pkg/galera/agent/router/router.go index fc5aecc91d..e1615d2a0c 100644 --- a/pkg/galera/agent/router/router.go +++ b/pkg/galera/agent/router/router.go @@ -56,11 +56,11 @@ func WithBasicAuth(auth bool, user, pass string) Option { } } -func NewRouter(handler *handler.Handler, k8sClient ctrlclient.Client, logger logr.Logger, opts ...Option) http.Handler { +func NewGaleraRouter(handler *handler.Galera, k8sClient ctrlclient.Client, logger logr.Logger, opts ...Option) http.Handler { routerOpts := Options{ - CompressLevel: 5, - KubernetesAuth: false, - KubernetesTrusted: nil, + CompressLevel: 5, + KubernetesAuth: false, + BasicAuth: false, } for _, setOpt := range opts { setOpt(&routerOpts) @@ -73,13 +73,34 @@ func NewRouter(handler *handler.Handler, k8sClient ctrlclient.Client, logger log w.WriteHeader(http.StatusOK) }) r.Mount("/api", apiRouter(handler, k8sClient, logger, &routerOpts)) - r.Get("/liveness", handler.Probe.Liveness) - r.Get("/readiness", handler.Probe.Readiness) return r } -func apiRouter(h *handler.Handler, k8sClient ctrlclient.Client, logger logr.Logger, opts *Options) http.Handler { +func NewProbeRouter(handler *handler.Probe, logger logr.Logger, opts ...Option) http.Handler { + routerOpts := Options{ + CompressLevel: 5, + } + for _, setOpt := range opts { + setOpt(&routerOpts) + } + routerOpts.KubernetesAuth = false + routerOpts.BasicAuth = false + + r := chi.NewRouter() + r.Use(middleware.Compress(routerOpts.CompressLevel)) + r.Use(middleware.Recoverer) + + r.Get("/health", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + }) + r.Get("/liveness", handler.Liveness) + r.Get("/readiness", handler.Readiness) + + return r +} + +func apiRouter(h *handler.Galera, k8sClient ctrlclient.Client, logger logr.Logger, opts *Options) http.Handler { r := chi.NewRouter() if opts.RateLimitRequests != nil && opts.RateLimitDuration != nil { r.Use(httprate.LimitAll(*opts.RateLimitRequests, *opts.RateLimitDuration)) @@ -93,11 +114,11 @@ func apiRouter(h *handler.Handler, k8sClient ctrlclient.Client, logger logr.Logg } r.Route("/galera", func(r chi.Router) { - r.Get("/state", h.Galera.GetState) + r.Get("/state", h.GetState) r.Route("/bootstrap", func(r chi.Router) { - r.Get("/", h.Galera.IsBootstrapEnabled) - r.Put("/", h.Galera.EnableBootstrap) - r.Delete("/", h.Galera.DisableBootstrap) + r.Get("/", h.IsBootstrapEnabled) + r.Put("/", h.EnableBootstrap) + r.Delete("/", h.DisableBootstrap) }) }) diff --git a/pkg/galera/agent/server/server.go b/pkg/galera/agent/server/server.go index d83553be37..0775a55ad0 100644 --- a/pkg/galera/agent/server/server.go +++ b/pkg/galera/agent/server/server.go @@ -2,11 +2,12 @@ package server import ( "context" + "crypto/tls" + "crypto/x509" + "errors" "fmt" "net/http" "os" - "os/signal" - "syscall" "time" "github.com/go-logr/logr" @@ -20,13 +21,42 @@ func WithGracefulShutdownTimeout(timeout time.Duration) Option { } } +func WithTLSEnabled(tlsEnabled bool) Option { + return func(s *Server) { + s.tlsEnabled = tlsEnabled + } +} + +func WithTLSCAPath(tlsCACertPath string) Option { + return func(s *Server) { + s.tlsCACertPath = tlsCACertPath + } +} + +func WithTLSCertPath(tlsCertPath string) Option { + return func(s *Server) { + s.tlsCertPath = tlsCertPath + } +} + +func WithTLSKeyPath(tlsKeyPath string) Option { + return func(s *Server) { + s.tlsKeyPath = tlsKeyPath + } +} + type Server struct { httpServer *http.Server logger *logr.Logger gracefulShutdownTimeout time.Duration + + tlsEnabled bool + tlsCACertPath string + tlsCertPath string + tlsKeyPath string } -func NewServer(addr string, handler http.Handler, logger *logr.Logger, opts ...Option) *Server { +func NewServer(addr string, handler http.Handler, logger *logr.Logger, opts ...Option) (*Server, error) { srv := &Server{ httpServer: &http.Server{ Addr: addr, @@ -38,36 +68,37 @@ func NewServer(addr string, handler http.Handler, logger *logr.Logger, opts ...O for _, setOpt := range opts { setOpt(srv) } - return srv + + if srv.tlsEnabled { + srv.logger.Info("Configuring TLS") + tlsConfig, err := srv.getTLSConfig() + if err != nil { + return nil, fmt.Errorf("error getting TLS config: %v", err) + } + srv.httpServer.TLSConfig = tlsConfig + } + return srv, nil } func (s *Server) Start(ctx context.Context) error { - serverContext, stopServer := context.WithCancel(ctx) + serverContext, stopServer := context.WithCancel(context.Background()) errChan := make(chan error) - sig := make(chan os.Signal, 1) - signal.Notify(sig, syscall.SIGHUP, syscall.SIGINT, syscall.SIGTERM, syscall.SIGQUIT) - go func() { - <-sig - defer stopServer() - - shutdownCtx, cancel := context.WithTimeout(serverContext, s.gracefulShutdownTimeout) + <-ctx.Done() + shutdownCtx, cancel := context.WithTimeout(context.Background(), s.gracefulShutdownTimeout) defer cancel() - go func() { - <-shutdownCtx.Done() - s.logger.Info("Graceful shutdown timed out") - }() - s.logger.Info("Shutting down server") + s.logger.Info("Gracefully shutting down server") if err := s.httpServer.Shutdown(shutdownCtx); err != nil { errChan <- fmt.Errorf("error shutting down server: %v", err) } - }() + s.logger.Info("Stopping server") + stopServer() + }() go func() { - s.logger.Info("Server listening", "addr", s.httpServer.Addr) - if err := s.httpServer.ListenAndServe(); err != http.ErrServerClosed { + if err := s.listen(); err != nil { errChan <- fmt.Errorf("Error starting server: %v", err) } }() @@ -79,3 +110,45 @@ func (s *Server) Start(ctx context.Context) error { return err } } + +func (s *Server) listen() error { + logger := s.logger.WithValues("addr", s.httpServer.Addr, "tls", s.tlsEnabled) + listenFn := func() error { + if s.tlsEnabled { + return s.httpServer.ListenAndServeTLS("", "") + } + return s.httpServer.ListenAndServe() + } + + logger.Info("Server listening") + if err := listenFn(); err != http.ErrServerClosed { + return err + } + return nil +} + +func (s *Server) getTLSConfig() (*tls.Config, error) { + if !s.tlsEnabled { + return nil, errors.New("TLS must be enabled") + } + caCert, err := os.ReadFile(s.tlsCACertPath) + if err != nil { + return nil, fmt.Errorf("error reading CA cert: %v", err) + } + caCertPool := x509.NewCertPool() + if ok := caCertPool.AppendCertsFromPEM(caCert); !ok { + return nil, errors.New("unable to add CA cert to pool") + } + + cert, err := tls.LoadX509KeyPair(s.tlsCertPath, s.tlsKeyPath) + if err != nil { + return nil, fmt.Errorf("error loading x509 keypair: %v", err) + } + + return &tls.Config{ + ClientCAs: caCertPool, + ClientAuth: tls.RequireAndVerifyClientCert, + Certificates: []tls.Certificate{cert}, + InsecureSkipVerify: false, + }, nil +} diff --git a/pkg/galera/config/config.go b/pkg/galera/config/config.go index 0ab2c1a527..ff0d572832 100644 --- a/pkg/galera/config/config.go +++ b/pkg/galera/config/config.go @@ -10,8 +10,10 @@ import ( "strings" "text/template" + "github.com/go-logr/logr" mariadbv1alpha1 "github.com/mariadb-operator/mariadb-operator/api/v1alpha1" galeraresources "github.com/mariadb-operator/mariadb-operator/pkg/controller/galera/resources" + "github.com/mariadb-operator/mariadb-operator/pkg/discovery" "github.com/mariadb-operator/mariadb-operator/pkg/environment" galerakeys "github.com/mariadb-operator/mariadb-operator/pkg/galera/config/keys" "github.com/mariadb-operator/mariadb-operator/pkg/galera/recovery" @@ -28,12 +30,16 @@ var BootstrapFile = []byte(`[galera] wsrep_new_cluster="ON"`) type ConfigFile struct { - mariadb *mariadbv1alpha1.MariaDB + mariadb *mariadbv1alpha1.MariaDB + discovery *discovery.Discovery + logger logr.Logger } -func NewConfigFile(mariadb *mariadbv1alpha1.MariaDB) *ConfigFile { +func NewConfigFile(mariadb *mariadbv1alpha1.MariaDB, discovery *discovery.Discovery, logger logr.Logger) *ConfigFile { return &ConfigFile{ - mariadb: mariadb, + mariadb: mariadb, + discovery: discovery, + logger: logger, } } @@ -43,6 +49,10 @@ func (c *ConfigFile) Marshal(podEnv *environment.PodEnvironment) ([]byte, error) } galera := ptr.Deref(c.mariadb.Spec.Galera, mariadbv1alpha1.Galera{}) + tls := ptr.Deref(c.mariadb.Spec.TLS, mariadbv1alpha1.TLS{}) + galeraServerSSLMode := ptr.Deref(tls.GaleraServerSSLMode, "") + galeraClientSSLMode := ptr.Deref(tls.GaleraClientSSLMode, "") + tpl := createTpl("galera", `[mariadb] bind_address=* default_storage_engine=InnoDB @@ -54,27 +64,44 @@ wsrep_on=ON wsrep_cluster_address="{{ .ClusterAddress }}" wsrep_cluster_name=mariadb-operator wsrep_slave_threads={{ .Threads }} +{{- if and .SSLEnabled .ClusterSSLMode }} +wsrep_ssl_mode={{ .ClusterSSLMode }} +{{- end }} # Node {{ .NodeAddressKey }}="{{ .NodeAddress }}" wsrep_node_name="{{ .NodeName }}" +# Provider +wsrep_provider={{ .GaleraLibPath }} +{{ .ProviderOptsKey }}="{{ .ProviderOpts }}" + # SST wsrep_sst_method="{{ .SST }}" {{- if .SSTAuth }} wsrep_sst_auth="root:{{ .RootPassword }}" {{- end }} {{ .SSTReceiveAddressKey }}="{{ .SSTReceiveAddress }}" - -# Provider -wsrep_provider={{ .GaleraLibPath }} -{{ .ProviderOptsKey }}="{{ .ProviderOpts }}" +{{- if .SSLEnabled }} +[sst] +{{- if .SSLMode }} +ssl_mode={{ .SSLMode }} +{{- end }} +encrypt=3 +tca={{ .SSLCAPath }} +tcert={{ .SSLCertPath }} +tkey={{ .SSLKeyPath }} +{{- end }} `) buf := new(bytes.Buffer) clusterAddr, err := c.clusterAddress() if err != nil { return nil, fmt.Errorf("error getting cluster address: %v", err) } + isEnterpriseTLSEnabled, err := c.mariadb.IsGaleraEnterpriseTLSAvailable(c.discovery, c.mariadb.Status.DefaultVersion, c.logger) + if err != nil { + c.logger.Error(err, "error checking whether TLS enterprise is enabled") + } sst, err := galera.SST.MariaDBFormat() if err != nil { @@ -85,7 +112,7 @@ wsrep_provider={{ .GaleraLibPath }} return nil, fmt.Errorf("error getting SST receive address: %v", err) } - providerOptions, err := getProviderOptions(podEnv.PodIP, galera.ProviderOptions) + providerOptions, err := c.getProviderOptions(podEnv, galera.ProviderOptions) if err != nil { return nil, fmt.Errorf("error getting provider options: %v", err) } @@ -98,15 +125,22 @@ wsrep_provider={{ .GaleraLibPath }} NodeAddress string NodeName string + GaleraLibPath string + ProviderOptsKey string + ProviderOpts string + SST string SSTAuth bool RootPassword string SSTReceiveAddressKey string SSTReceiveAddress string - GaleraLibPath string - ProviderOptsKey string - ProviderOpts string + SSLEnabled bool + ClusterSSLMode string + SSLMode string + SSLCAPath string + SSLCertPath string + SSLKeyPath string }{ ClusterAddress: clusterAddr, Threads: galera.ReplicaThreads, @@ -115,15 +149,22 @@ wsrep_provider={{ .GaleraLibPath }} NodeAddress: podEnv.PodIP, NodeName: podEnv.PodName, + GaleraLibPath: galera.GaleraLibPath, + ProviderOptsKey: galerakeys.WsrepProviderOptionsKey, + ProviderOpts: providerOptions, + SST: sst, SSTAuth: galera.SST == mariadbv1alpha1.SSTMariaBackup || galera.SST == mariadbv1alpha1.SSTMysqldump, RootPassword: podEnv.MariadbRootPassword, SSTReceiveAddressKey: galerakeys.WsrepSSTReceiveAddressKey, SSTReceiveAddress: sstReceiveAddress, - GaleraLibPath: galera.GaleraLibPath, - ProviderOptsKey: galerakeys.WsrepProviderOptionsKey, - ProviderOpts: providerOptions, + SSLEnabled: c.mariadb.IsTLSEnabled(), + ClusterSSLMode: c.enterpriseTLSValue(isEnterpriseTLSEnabled, galeraServerSSLMode), + SSLMode: c.enterpriseTLSValue(isEnterpriseTLSEnabled, galeraClientSSLMode), + SSLCAPath: podEnv.TLSCACertPath, + SSLCertPath: podEnv.TLSClientCertPath, + SSLKeyPath: podEnv.TLSClientKeyPath, }) if err != nil { return nil, err @@ -146,32 +187,19 @@ func (c *ConfigFile) clusterAddress() (string, error) { return fmt.Sprintf("gcomm://%s", strings.Join(pods, ",")), nil } -func UpdateConfig(configBytes []byte, podEnv *environment.PodEnvironment) ([]byte, error) { - fileScanner := bufio.NewScanner(bytes.NewReader(configBytes)) - fileScanner.Split(bufio.ScanLines) - - var updatedLines []string - for fileScanner.Scan() { - line, err := getUpdatedConfigLine(fileScanner.Text(), podEnv.PodIP) - if err != nil { - return nil, err - } - updatedLines = append(updatedLines, line) +func (c *ConfigFile) enterpriseTLSValue(isEnterpriseTLSEnabled bool, value string) string { + if isEnterpriseTLSEnabled { + return value } - if err := fileScanner.Err(); err != nil { - return nil, fmt.Errorf("error reading config: %v", err) - } - - updatedConfig := []byte(strings.Join(updatedLines, "\n")) - return updatedConfig, nil + return "" } -func getProviderOptions(podIP string, options map[string]string) (string, error) { - gmcastListenAddress, err := getGmcastListenAddress(podIP) +func (c *ConfigFile) getProviderOptions(env *environment.PodEnvironment, options map[string]string) (string, error) { + gmcastListenAddress, err := getGmcastListenAddress(env.PodIP) if err != nil { return "", fmt.Errorf("error getting gcomm listden address: %v", err) } - istReceiveAddress, err := getISTReceiveAddress(podIP) + istReceiveAddress, err := getISTReceiveAddress(env.PodIP) if err != nil { return "", fmt.Errorf("error getting IST receive address: %v", err) } @@ -180,12 +208,48 @@ func getProviderOptions(podIP string, options map[string]string) (string, error) galerakeys.WsrepOptGmcastListAddr: gmcastListenAddress, galerakeys.WsrepOptISTRecvAddr: istReceiveAddress, } + + if c.mariadb.IsTLSEnabled() { + wsrepOpts[galerakeys.WsrepOptSocketSSL] = "true" + if env.TLSCACertPath != "" { + wsrepOpts[galerakeys.WsrepOptSocketSSLCA] = env.TLSCACertPath + } + if env.TLSServerCertPath != "" { + wsrepOpts[galerakeys.WsrepOptSocketSSLCert] = env.TLSServerCertPath + } + if env.TLSServerKeyPath != "" { + wsrepOpts[galerakeys.WsrepOptSocketSSLKey] = env.TLSServerKeyPath + } + } else { + wsrepOpts[galerakeys.WsrepOptSocketSSL] = "false" + } + maps.Copy(wsrepOpts, options) providerOpts := newProviderOptions(wsrepOpts) return providerOpts.marshal(), nil } +func UpdateConfig(configBytes []byte, podEnv *environment.PodEnvironment) ([]byte, error) { + fileScanner := bufio.NewScanner(bytes.NewReader(configBytes)) + fileScanner.Split(bufio.ScanLines) + + var updatedLines []string + for fileScanner.Scan() { + line, err := getUpdatedConfigLine(fileScanner.Text(), podEnv.PodIP) + if err != nil { + return nil, err + } + updatedLines = append(updatedLines, line) + } + if err := fileScanner.Err(); err != nil { + return nil, fmt.Errorf("error reading config: %v", err) + } + + updatedConfig := []byte(strings.Join(updatedLines, "\n")) + return updatedConfig, nil +} + func getSSTReceiveAddress(podIP string) (string, error) { wrappedPodIP, err := wrapIPAddress(podIP) if err != nil { diff --git a/pkg/galera/config/config_test.go b/pkg/galera/config/config_test.go index 236d2cdf48..e60f4724a8 100644 --- a/pkg/galera/config/config_test.go +++ b/pkg/galera/config/config_test.go @@ -1,21 +1,25 @@ package config import ( - "reflect" "testing" + "github.com/go-logr/logr" + "github.com/google/go-cmp/cmp" mariadbv1alpha1 "github.com/mariadb-operator/mariadb-operator/api/v1alpha1" + "github.com/mariadb-operator/mariadb-operator/pkg/discovery" "github.com/mariadb-operator/mariadb-operator/pkg/environment" v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/utils/ptr" ) func TestGaleraConfigMarshal(t *testing.T) { tests := []struct { - name string - mariadb *mariadbv1alpha1.MariaDB - podEnv *environment.PodEnvironment - wantConfig string - wantErr bool + name string + mariadb *mariadbv1alpha1.MariaDB + podEnv *environment.PodEnvironment + isEnterprise bool + wantConfig string + wantErr bool }{ { name: "no replicas", @@ -40,8 +44,9 @@ func TestGaleraConfigMarshal(t *testing.T) { PodIP: "10.244.0.32", MariadbRootPassword: "mariadb", }, - wantConfig: "", - wantErr: true, + isEnterprise: false, + wantConfig: "", + wantErr: true, }, { name: "Galera not enabled", @@ -62,8 +67,9 @@ func TestGaleraConfigMarshal(t *testing.T) { PodIP: "10.244.0.32", MariadbRootPassword: "mariadb", }, - wantConfig: "", - wantErr: true, + isEnterprise: false, + wantConfig: "", + wantErr: true, }, { name: "invalid IP", @@ -84,8 +90,9 @@ func TestGaleraConfigMarshal(t *testing.T) { PodIP: "foo", MariadbRootPassword: "mariadb", }, - wantConfig: "", - wantErr: true, + isEnterprise: false, + wantConfig: "", + wantErr: true, }, { name: "rsync", @@ -111,6 +118,7 @@ func TestGaleraConfigMarshal(t *testing.T) { PodIP: "10.244.0.32", MariadbRootPassword: "mariadb", }, + isEnterprise: false, //nolint:lll wantConfig: `[mariadb] bind_address=* @@ -128,13 +136,13 @@ wsrep_slave_threads=1 wsrep_node_address="10.244.0.32" wsrep_node_name="mariadb-galera-0" +# Provider +wsrep_provider=/usr/lib/galera/libgalera_smm.so +wsrep_provider_options="gmcast.listen_addr=tcp://0.0.0.0:4567;ist.recv_addr=10.244.0.32:4568;socket.ssl=false" + # SST wsrep_sst_method="rsync" wsrep_sst_receive_address="10.244.0.32:4444" - -# Provider -wsrep_provider=/usr/lib/galera/libgalera_smm.so -wsrep_provider_options="gmcast.listen_addr=tcp://0.0.0.0:4567;ist.recv_addr=10.244.0.32:4568" `, wantErr: false, }, @@ -162,6 +170,7 @@ wsrep_provider_options="gmcast.listen_addr=tcp://0.0.0.0:4567;ist.recv_addr=10.2 PodIP: "10.244.0.32", MariadbRootPassword: "mariadb", }, + isEnterprise: false, //nolint:lll wantConfig: `[mariadb] bind_address=* @@ -179,14 +188,14 @@ wsrep_slave_threads=2 wsrep_node_address="10.244.0.32" wsrep_node_name="mariadb-galera-1" +# Provider +wsrep_provider=/usr/lib/galera/libgalera_enterprise_smm.so +wsrep_provider_options="gmcast.listen_addr=tcp://0.0.0.0:4567;ist.recv_addr=10.244.0.32:4568;socket.ssl=false" + # SST wsrep_sst_method="mariabackup" wsrep_sst_auth="root:mariadb" wsrep_sst_receive_address="10.244.0.32:4444" - -# Provider -wsrep_provider=/usr/lib/galera/libgalera_enterprise_smm.so -wsrep_provider_options="gmcast.listen_addr=tcp://0.0.0.0:4567;ist.recv_addr=10.244.0.32:4568" `, wantErr: false, }, @@ -214,6 +223,7 @@ wsrep_provider_options="gmcast.listen_addr=tcp://0.0.0.0:4567;ist.recv_addr=10.2 PodIP: "2001:db8::a1", MariadbRootPassword: "mariadb", }, + isEnterprise: false, //nolint:lll wantConfig: `[mariadb] bind_address=* @@ -231,14 +241,14 @@ wsrep_slave_threads=1 wsrep_node_address="2001:db8::a1" wsrep_node_name="mariadb-galera-1" +# Provider +wsrep_provider=/usr/lib/galera/libgalera_enterprise_smm.so +wsrep_provider_options="gmcast.listen_addr=tcp://[::]:4567;ist.recv_addr=[2001:db8::a1]:4568;socket.ssl=false" + # SST wsrep_sst_method="mariabackup" wsrep_sst_auth="root:mariadb" wsrep_sst_receive_address="[2001:db8::a1]:4444" - -# Provider -wsrep_provider=/usr/lib/galera/libgalera_enterprise_smm.so -wsrep_provider_options="gmcast.listen_addr=tcp://[::]:4567;ist.recv_addr=[2001:db8::a1]:4568" `, wantErr: false, }, @@ -270,6 +280,7 @@ wsrep_provider_options="gmcast.listen_addr=tcp://[::]:4567;ist.recv_addr=[2001:d PodIP: "2001:db8::a1", MariadbRootPassword: "mariadb", }, + isEnterprise: false, //nolint:lll wantConfig: `[mariadb] bind_address=* @@ -287,14 +298,222 @@ wsrep_slave_threads=1 wsrep_node_address="2001:db8::a1" wsrep_node_name="mariadb-galera-1" +# Provider +wsrep_provider=/usr/lib/galera/libgalera_enterprise_smm.so +wsrep_provider_options="gcache.size=1G;gcs.fc_limit=128;gmcast.listen_addr=tcp://[::]:4567;ist.recv_addr=[2001:db8::a1]:4568;socket.ssl=false" + # SST wsrep_sst_method="mariabackup" wsrep_sst_auth="root:mariadb" wsrep_sst_receive_address="[2001:db8::a1]:4444" +`, + wantErr: false, + }, + { + name: "TLS", + mariadb: &mariadbv1alpha1.MariaDB{ + ObjectMeta: v1.ObjectMeta{ + Name: "mariadb-galera", + Namespace: "default", + }, + Spec: mariadbv1alpha1.MariaDBSpec{ + Image: "mariadb:10.11.8", + Galera: &mariadbv1alpha1.Galera{ + Enabled: true, + GaleraSpec: mariadbv1alpha1.GaleraSpec{ + SST: mariadbv1alpha1.SSTMariaBackup, + GaleraLibPath: "/usr/lib/galera/libgalera_enterprise_smm.so", + ReplicaThreads: 2, + }, + }, + TLS: &mariadbv1alpha1.TLS{ + Enabled: true, + }, + Replicas: 3, + }, + }, + podEnv: &environment.PodEnvironment{ + PodName: "mariadb-galera-1", + PodIP: "10.244.0.32", + MariadbRootPassword: "mariadb", + TLSEnabled: "true", + TLSCACertPath: "/etc/pki/ca.crt", + TLSServerCertPath: "/etc/pki/server.crt", + TLSServerKeyPath: "/etc/pki/server.key", + TLSClientCertPath: "/etc/pki/client.crt", + TLSClientKeyPath: "/etc/pki/client.key", + }, + isEnterprise: false, + //nolint:lll + wantConfig: `[mariadb] +bind_address=* +default_storage_engine=InnoDB +binlog_format=row +innodb_autoinc_lock_mode=2 + +# Cluster +wsrep_on=ON +wsrep_cluster_address="gcomm://mariadb-galera-0.mariadb-galera-internal.default.svc.cluster.local,mariadb-galera-1.mariadb-galera-internal.default.svc.cluster.local,mariadb-galera-2.mariadb-galera-internal.default.svc.cluster.local" +wsrep_cluster_name=mariadb-operator +wsrep_slave_threads=2 + +# Node +wsrep_node_address="10.244.0.32" +wsrep_node_name="mariadb-galera-1" # Provider wsrep_provider=/usr/lib/galera/libgalera_enterprise_smm.so -wsrep_provider_options="gcache.size=1G;gcs.fc_limit=128;gmcast.listen_addr=tcp://[::]:4567;ist.recv_addr=[2001:db8::a1]:4568" +wsrep_provider_options="gmcast.listen_addr=tcp://0.0.0.0:4567;ist.recv_addr=10.244.0.32:4568;socket.ssl=true;socket.ssl_ca=/etc/pki/ca.crt;socket.ssl_cert=/etc/pki/server.crt;socket.ssl_key=/etc/pki/server.key" + +# SST +wsrep_sst_method="mariabackup" +wsrep_sst_auth="root:mariadb" +wsrep_sst_receive_address="10.244.0.32:4444" +[sst] +encrypt=3 +tca=/etc/pki/ca.crt +tcert=/etc/pki/client.crt +tkey=/etc/pki/client.key +`, + wantErr: false, + }, + { + name: "TLS enterprise", + mariadb: &mariadbv1alpha1.MariaDB{ + ObjectMeta: v1.ObjectMeta{ + Name: "mariadb-galera", + Namespace: "default", + }, + Spec: mariadbv1alpha1.MariaDBSpec{ + Image: "docker.mariadb.com/enterprise-server:10.6", + Galera: &mariadbv1alpha1.Galera{ + Enabled: true, + GaleraSpec: mariadbv1alpha1.GaleraSpec{ + SST: mariadbv1alpha1.SSTMariaBackup, + GaleraLibPath: "/usr/lib/galera/libgalera_enterprise_smm.so", + ReplicaThreads: 2, + }, + }, + TLS: &mariadbv1alpha1.TLS{ + Enabled: true, + }, + Replicas: 3, + }, + }, + podEnv: &environment.PodEnvironment{ + PodName: "mariadb-galera-1", + PodIP: "10.244.0.32", + MariadbRootPassword: "mariadb", + TLSEnabled: "true", + TLSCACertPath: "/etc/pki/ca.crt", + TLSServerCertPath: "/etc/pki/server.crt", + TLSServerKeyPath: "/etc/pki/server.key", + TLSClientCertPath: "/etc/pki/client.crt", + TLSClientKeyPath: "/etc/pki/client.key", + }, + isEnterprise: true, + //nolint:lll + wantConfig: `[mariadb] +bind_address=* +default_storage_engine=InnoDB +binlog_format=row +innodb_autoinc_lock_mode=2 + +# Cluster +wsrep_on=ON +wsrep_cluster_address="gcomm://mariadb-galera-0.mariadb-galera-internal.default.svc.cluster.local,mariadb-galera-1.mariadb-galera-internal.default.svc.cluster.local,mariadb-galera-2.mariadb-galera-internal.default.svc.cluster.local" +wsrep_cluster_name=mariadb-operator +wsrep_slave_threads=2 + +# Node +wsrep_node_address="10.244.0.32" +wsrep_node_name="mariadb-galera-1" + +# Provider +wsrep_provider=/usr/lib/galera/libgalera_enterprise_smm.so +wsrep_provider_options="gmcast.listen_addr=tcp://0.0.0.0:4567;ist.recv_addr=10.244.0.32:4568;socket.ssl=true;socket.ssl_ca=/etc/pki/ca.crt;socket.ssl_cert=/etc/pki/server.crt;socket.ssl_key=/etc/pki/server.key" + +# SST +wsrep_sst_method="mariabackup" +wsrep_sst_auth="root:mariadb" +wsrep_sst_receive_address="10.244.0.32:4444" +[sst] +encrypt=3 +tca=/etc/pki/ca.crt +tcert=/etc/pki/client.crt +tkey=/etc/pki/client.key +`, + wantErr: false, + }, + { + name: "TLS enterprise with SSL mode", + mariadb: &mariadbv1alpha1.MariaDB{ + ObjectMeta: v1.ObjectMeta{ + Name: "mariadb-galera", + Namespace: "default", + }, + Spec: mariadbv1alpha1.MariaDBSpec{ + Image: "docker.mariadb.com/enterprise-server:10.6", + Galera: &mariadbv1alpha1.Galera{ + Enabled: true, + GaleraSpec: mariadbv1alpha1.GaleraSpec{ + SST: mariadbv1alpha1.SSTMariaBackup, + GaleraLibPath: "/usr/lib/galera/libgalera_enterprise_smm.so", + ReplicaThreads: 2, + }, + }, + TLS: &mariadbv1alpha1.TLS{ + Enabled: true, + GaleraServerSSLMode: ptr.To("SERVER_X509"), + GaleraClientSSLMode: ptr.To("VERIFY_IDENTITY"), + }, + Replicas: 3, + }, + }, + podEnv: &environment.PodEnvironment{ + PodName: "mariadb-galera-1", + PodIP: "10.244.0.32", + MariadbRootPassword: "mariadb", + TLSEnabled: "true", + TLSCACertPath: "/etc/pki/ca.crt", + TLSServerCertPath: "/etc/pki/server.crt", + TLSServerKeyPath: "/etc/pki/server.key", + TLSClientCertPath: "/etc/pki/client.crt", + TLSClientKeyPath: "/etc/pki/client.key", + }, + isEnterprise: true, + //nolint:lll + wantConfig: `[mariadb] +bind_address=* +default_storage_engine=InnoDB +binlog_format=row +innodb_autoinc_lock_mode=2 + +# Cluster +wsrep_on=ON +wsrep_cluster_address="gcomm://mariadb-galera-0.mariadb-galera-internal.default.svc.cluster.local,mariadb-galera-1.mariadb-galera-internal.default.svc.cluster.local,mariadb-galera-2.mariadb-galera-internal.default.svc.cluster.local" +wsrep_cluster_name=mariadb-operator +wsrep_slave_threads=2 +wsrep_ssl_mode=SERVER_X509 + +# Node +wsrep_node_address="10.244.0.32" +wsrep_node_name="mariadb-galera-1" + +# Provider +wsrep_provider=/usr/lib/galera/libgalera_enterprise_smm.so +wsrep_provider_options="gmcast.listen_addr=tcp://0.0.0.0:4567;ist.recv_addr=10.244.0.32:4568;socket.ssl=true;socket.ssl_ca=/etc/pki/ca.crt;socket.ssl_cert=/etc/pki/server.crt;socket.ssl_key=/etc/pki/server.key" + +# SST +wsrep_sst_method="mariabackup" +wsrep_sst_auth="root:mariadb" +wsrep_sst_receive_address="10.244.0.32:4444" +[sst] +ssl_mode=VERIFY_IDENTITY +encrypt=3 +tca=/etc/pki/ca.crt +tcert=/etc/pki/client.crt +tkey=/etc/pki/client.key `, wantErr: false, }, @@ -302,16 +521,21 @@ wsrep_provider_options="gcache.size=1G;gcs.fc_limit=128;gmcast.listen_addr=tcp:/ for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - config := NewConfigFile(tt.mariadb) - bytes, err := config.Marshal(tt.podEnv) + discovery, err := discovery.NewFakeDiscovery(tt.isEnterprise) + if err != nil { + t.Fatalf("unexpected error creating discovery: %v", err) + } + + bytes, err := NewConfigFile(tt.mariadb, discovery, logr.Discard()).Marshal(tt.podEnv) + if tt.wantErr && err == nil { - t.Error("error expected, got nil") + t.Error("expect error to have occurred, got nil") } if !tt.wantErr && err != nil { - t.Errorf("error unexpected, got %v", err) + t.Errorf("expect error to not have occurred, got: %v", err) } - if tt.wantConfig != string(bytes) { - t.Errorf("unexpected result:\nexpected:\n%s\ngot:\n%s\n", tt.wantConfig, string(bytes)) + if diff := cmp.Diff(tt.wantConfig, string(bytes)); diff != "" { + t.Errorf("unexpected config (-want +got):\n%s", diff) } }) } @@ -319,11 +543,11 @@ wsrep_provider_options="gcache.size=1G;gcs.fc_limit=128;gmcast.listen_addr=tcp:/ func TestGaleraConfigUpdate(t *testing.T) { tests := []struct { - name string - config string - podEnv *environment.PodEnvironment - wantBytes []byte - wantErr bool + name string + config string + podEnv *environment.PodEnvironment + wantConfig []byte + wantErr bool }{ { name: "invalid IP", @@ -343,19 +567,19 @@ wsrep_slave_threads=2 wsrep_node_address="10.244.0.32" wsrep_node_name="mariadb-galera-1" +# Provider +wsrep_provider=/usr/lib/galera/libgalera_enterprise_smm.so +wsrep_provider_options="gmcast.listen_addr=tcp://0.0.0.0:4567;ist.recv_addr=10.244.0.32:4568" + # SST wsrep_sst_method="mariabackup" wsrep_sst_auth="root:mariadb" -wsrep_sst_receive_address="10.244.0.32:4444" - -# Provider -wsrep_provider=/usr/lib/galera/libgalera_enterprise_smm.so -wsrep_provider_options="gmcast.listen_addr=tcp://0.0.0.0:4567;ist.recv_addr=10.244.0.32:4568"`, +wsrep_sst_receive_address="10.244.0.32:4444"`, podEnv: &environment.PodEnvironment{ PodIP: "foo", }, - wantBytes: nil, - wantErr: true, + wantConfig: nil, + wantErr: true, }, { name: "IPv4", @@ -375,18 +599,18 @@ wsrep_slave_threads=2 wsrep_node_address="10.244.0.32" wsrep_node_name="mariadb-galera-1" +# Provider +wsrep_provider=/usr/lib/galera/libgalera_enterprise_smm.so +wsrep_provider_options="gmcast.listen_addr=tcp://0.0.0.0:4567;ist.recv_addr=10.244.0.32:4568" + # SST wsrep_sst_method="mariabackup" wsrep_sst_auth="root:mariadb" -wsrep_sst_receive_address="10.244.0.32:4444" - -# Provider -wsrep_provider=/usr/lib/galera/libgalera_enterprise_smm.so -wsrep_provider_options="gmcast.listen_addr=tcp://0.0.0.0:4567;ist.recv_addr=10.244.0.32:4568"`, +wsrep_sst_receive_address="10.244.0.32:4444"`, podEnv: &environment.PodEnvironment{ PodIP: "10.244.0.33", }, - wantBytes: []byte(`[mariadb] + wantConfig: []byte(`[mariadb] bind_address=* default_storage_engine=InnoDB binlog_format=row @@ -402,14 +626,14 @@ wsrep_slave_threads=2 wsrep_node_address="10.244.0.33" wsrep_node_name="mariadb-galera-1" +# Provider +wsrep_provider=/usr/lib/galera/libgalera_enterprise_smm.so +wsrep_provider_options="gmcast.listen_addr=tcp://0.0.0.0:4567;ist.recv_addr=10.244.0.33:4568" + # SST wsrep_sst_method="mariabackup" wsrep_sst_auth="root:mariadb" -wsrep_sst_receive_address="10.244.0.33:4444" - -# Provider -wsrep_provider=/usr/lib/galera/libgalera_enterprise_smm.so -wsrep_provider_options="gmcast.listen_addr=tcp://0.0.0.0:4567;ist.recv_addr=10.244.0.33:4568"`), +wsrep_sst_receive_address="10.244.0.33:4444"`), wantErr: false, }, { @@ -430,18 +654,18 @@ wsrep_slave_threads=1 wsrep_node_address="2001:db8::a1" wsrep_node_name="mariadb-galera-1" +# Provider +wsrep_provider=/usr/lib/galera/libgalera_enterprise_smm.so +wsrep_provider_options="gcache.size=1G;gcs.fc_limit=128;gmcast.listen_addr=tcp://[::]:4567;ist.recv_addr=[2001:db8::a1]:4568" + # SST wsrep_sst_method="mariabackup" wsrep_sst_auth="root:mariadb" -wsrep_sst_receive_address="[2001:db8::a1]:4444" - -# Provider -wsrep_provider=/usr/lib/galera/libgalera_enterprise_smm.so -wsrep_provider_options="gcache.size=1G;gcs.fc_limit=128;gmcast.listen_addr=tcp://[::]:4567;ist.recv_addr=[2001:db8::a1]:4568"`, +wsrep_sst_receive_address="[2001:db8::a1]:4444"`, podEnv: &environment.PodEnvironment{ PodIP: "2001:db8::a2", }, - wantBytes: []byte(`[mariadb] + wantConfig: []byte(`[mariadb] bind_address=* default_storage_engine=InnoDB binlog_format=row @@ -457,14 +681,14 @@ wsrep_slave_threads=1 wsrep_node_address="2001:db8::a2" wsrep_node_name="mariadb-galera-1" +# Provider +wsrep_provider=/usr/lib/galera/libgalera_enterprise_smm.so +wsrep_provider_options="gcache.size=1G;gcs.fc_limit=128;gmcast.listen_addr=tcp://[::]:4567;ist.recv_addr=[2001:db8::a2]:4568" + # SST wsrep_sst_method="mariabackup" wsrep_sst_auth="root:mariadb" -wsrep_sst_receive_address="[2001:db8::a2]:4444" - -# Provider -wsrep_provider=/usr/lib/galera/libgalera_enterprise_smm.so -wsrep_provider_options="gcache.size=1G;gcs.fc_limit=128;gmcast.listen_addr=tcp://[::]:4567;ist.recv_addr=[2001:db8::a2]:4568"`), +wsrep_sst_receive_address="[2001:db8::a2]:4444"`), wantErr: false, }, } @@ -477,8 +701,8 @@ wsrep_provider_options="gcache.size=1G;gcs.fc_limit=128;gmcast.listen_addr=tcp:/ if !tt.wantErr && err != nil { t.Errorf("error unexpected, got %v", err) } - if !reflect.DeepEqual(tt.wantBytes, bytes) { - t.Errorf("unexpected result:\nexpected:\n%s\ngot:\n%s\n", string(tt.wantBytes), string(bytes)) + if diff := cmp.Diff(string(tt.wantConfig), string(bytes)); diff != "" { + t.Errorf("unexpected config (-want +got):\n%s", diff) } }) } diff --git a/pkg/galera/config/keys/keys.go b/pkg/galera/config/keys/keys.go index e10d2e90d4..25200b5396 100644 --- a/pkg/galera/config/keys/keys.go +++ b/pkg/galera/config/keys/keys.go @@ -8,4 +8,9 @@ var ( WsrepProviderOptionsKey = "wsrep_provider_options" WsrepOptISTRecvAddr = "ist.recv_addr" WsrepOptGmcastListAddr = "gmcast.listen_addr" + + WsrepOptSocketSSL = "socket.ssl" + WsrepOptSocketSSLCert = "socket.ssl_cert" + WsrepOptSocketSSLKey = "socket.ssl_key" + WsrepOptSocketSSLCA = "socket.ssl_ca" ) diff --git a/pkg/http/client.go b/pkg/http/client.go index fda471062e..2761549fe7 100644 --- a/pkg/http/client.go +++ b/pkg/http/client.go @@ -3,7 +3,10 @@ package http import ( "bytes" "context" + "crypto/tls" + "crypto/x509" b64 "encoding/base64" + "errors" "fmt" "io" "net/http" @@ -17,58 +20,98 @@ import ( var defaultTimeout = 10 * time.Second -type Option func(*Client) error +type Option func(*Opts) error + +type Opts struct { + httpClient *http.Client + headers map[string]string + version string + logger *logr.Logger + + tlsEnabled bool + tlsCACert []byte + tlsCert []byte + tlsKey []byte +} func WithHTTPClient(httpClient *http.Client) Option { - return func(c *Client) error { + return func(opts *Opts) error { if httpClient == nil { httpClient = http.DefaultClient } - c.httpClient = httpClient + opts.httpClient = httpClient return nil } } func WithTimeout(timeout time.Duration) Option { - return func(c *Client) error { + return func(opts *Opts) error { if timeout == 0 { timeout = defaultTimeout } - c.httpClient.Timeout = timeout + opts.httpClient.Timeout = timeout return nil } } func WithBasicAuth(username, password string) Option { - return func(c *Client) error { + return func(opts *Opts) error { raw := fmt.Sprintf("%s:%s", username, password) encoded := b64.StdEncoding.EncodeToString([]byte(raw)) - c.headers["Authorization"] = fmt.Sprintf("Basic %s", encoded) + opts.headers["Authorization"] = fmt.Sprintf("Basic %s", encoded) return nil } } func WithKubernetesAuth(serviceAccountPath string) Option { - return func(c *Client) error { + return func(opts *Opts) error { bytes, err := os.ReadFile(serviceAccountPath) if err != nil { return fmt.Errorf("error getting Kubernetes auth header: error reading '%s': %v", serviceAccountPath, err) } - c.headers["Authorization"] = fmt.Sprintf("Bearer %s", string(bytes)) + opts.headers["Authorization"] = fmt.Sprintf("Bearer %s", string(bytes)) return nil } } func WithVersion(version string) Option { - return func(c *Client) error { - c.version = strings.TrimPrefix(version, "/") + return func(opts *Opts) error { + opts.version = strings.TrimPrefix(version, "/") return nil } } func WithLogger(logger *logr.Logger) Option { - return func(c *Client) error { - c.logger = logger + return func(opts *Opts) error { + opts.logger = logger + return nil + } +} + +func WithTLSEnabled(tlsEnabled bool) Option { + return func(opts *Opts) error { + opts.tlsEnabled = tlsEnabled + return nil + } +} + +func WithTLSCA(tlsCACert []byte) Option { + return func(opts *Opts) error { + opts.tlsCACert = tlsCACert + return nil + } +} + +func WithTLSCert(tlsCert []byte) Option { + return func(opts *Opts) error { + opts.tlsCert = tlsCert + return nil + } +} + +func WithTLSKey(tlsKey []byte) Option { + return func(opts *Opts) error { + opts.tlsKey = tlsKey return nil } } @@ -86,19 +129,32 @@ func NewClient(baseUrl string, opts ...Option) (*Client, error) { if err != nil { return nil, fmt.Errorf("error parsing base URL: %v", err) } - client := &Client{ - baseUrl: url, + + clientOpts := Opts{ httpClient: &http.Client{ Timeout: defaultTimeout, }, headers: make(map[string]string, 0), } for _, setOpt := range opts { - if err := setOpt(client); err != nil { + if err := setOpt(&clientOpts); err != nil { return nil, err } } - client.httpClient.Transport = NewHeadersTransport(client.httpClient.Transport, client.headers) + + client := &Client{ + baseUrl: url, + httpClient: clientOpts.httpClient, + headers: clientOpts.headers, + version: clientOpts.version, + logger: clientOpts.logger, + } + + transport, err := client.getTransport(&clientOpts) + if err != nil { + return nil, fmt.Errorf("error getting transport: %v", err) + } + client.httpClient.Transport = NewHeadersTransport(transport, client.headers) return client, nil } @@ -188,3 +244,26 @@ func (c *Client) logDebug(msg string, kv ...interface{}) { } c.logger.V(1).Info(msg, kv...) } + +func (c *Client) getTransport(opts *Opts) (http.RoundTripper, error) { + if !opts.tlsEnabled { + return http.DefaultTransport, nil + } + caCertPool := x509.NewCertPool() + if ok := caCertPool.AppendCertsFromPEM(opts.tlsCACert); !ok { + return nil, errors.New("unable to add CA cert to pool") + } + + cert, err := tls.X509KeyPair(opts.tlsCert, opts.tlsKey) + if err != nil { + return nil, fmt.Errorf("error parsing x509 keypair: %v", err) + } + + return &http.Transport{ + TLSClientConfig: &tls.Config{ + RootCAs: caCertPool, + Certificates: []tls.Certificate{cert}, + InsecureSkipVerify: false, + }, + }, nil +} diff --git a/pkg/maxscale/client/listener.go b/pkg/maxscale/client/listener.go index ff36dd0745..a805c164c5 100644 --- a/pkg/maxscale/client/listener.go +++ b/pkg/maxscale/client/listener.go @@ -7,9 +7,15 @@ import ( ) type ListenerParameters struct { - Port int32 `json:"port"` - Protocol string `json:"protocol"` - Params MapParams `json:"-"` + Port int32 `json:"port"` + Protocol string `json:"protocol"` + SSL bool `json:"ssl,omitempty"` + SSLCert string `json:"ssl_cert,omitempty"` + SSLKey string `json:"ssl_key,omitempty"` + SSLCA string `json:"ssl_ca,omitempty"` + SSLVerifyPeerCertificate bool `json:"ssl_verify_peer_certificate,omitempty"` + SSLVerifyPeerHost bool `json:"ssl_verify_peer_host,omitempty"` + Params MapParams `json:"-"` } func (l ListenerParameters) MarshalJSON() ([]byte, error) { diff --git a/pkg/maxscale/client/server.go b/pkg/maxscale/client/server.go index 5d2ccad5d6..5ef5bd66c3 100644 --- a/pkg/maxscale/client/server.go +++ b/pkg/maxscale/client/server.go @@ -13,10 +13,18 @@ import ( var ErrMasterServerNotFound = errors.New("master server not found") type ServerParameters struct { - Address string `json:"address"` - Port int32 `json:"port"` - Protocol string `json:"protocol"` - Params MapParams `json:"-"` + Address string `json:"address"` + Port int32 `json:"port"` + Protocol string `json:"protocol"` + SSL bool `json:"ssl,omitempty"` + SSLCert string `json:"ssl_cert,omitempty"` + SSLKey string `json:"ssl_key,omitempty"` + SSLCA string `json:"ssl_ca,omitempty"` + SSLVersion string `json:"ssl_version,omitempty"` + SSLVerifyPeerCertificate bool `json:"ssl_verify_peer_certificate,omitempty"` + SSLVerifyPeerHost bool `json:"ssl_verify_peer_host,omitempty"` + ReplicationCustomOptions string `json:"replication_custom_options,omitempty"` + Params MapParams `json:"-"` } func (s ServerParameters) MarshalJSON() ([]byte, error) { diff --git a/pkg/maxscale/config/config.go b/pkg/maxscale/config/config.go index 8a2b8a37d7..cb214051e9 100644 --- a/pkg/maxscale/config/config.go +++ b/pkg/maxscale/config/config.go @@ -6,6 +6,7 @@ import ( "text/template" mariadbv1alpha1 "github.com/mariadb-operator/mariadb-operator/api/v1alpha1" + builderpki "github.com/mariadb-operator/mariadb-operator/pkg/builder/pki" "k8s.io/utils/ptr" ) @@ -17,7 +18,10 @@ type tplOpts struct { AdminHost string AdminPort int32 AdminGui bool - AdminSecureGui bool + TLSEnabled bool + TLSAdminKeyPath string + TLSAdminCertPath string + TLSAdminCAPath string Params map[string]string } @@ -30,6 +34,9 @@ var existingConfigKeys = map[string]struct{}{ "admin_port": {}, "admin_gui": {}, "admin_secure_gui": {}, + "admin_ssl_key": {}, + "admin_ssl_cert": {}, + "admin_ssl_ca_cert": {}, } func Config(mxs *mariadbv1alpha1.MaxScale) ([]byte, error) { @@ -43,7 +50,12 @@ load_persisted_configs={{ .LoadPersistentConfigs }} admin_host={{ .AdminHost }} admin_port={{ .AdminPort }} admin_gui={{ .AdminGui }} -admin_secure_gui={{ .AdminSecureGui }} +admin_secure_gui={{ .TLSEnabled }} +{{- if .TLSEnabled }} +admin_ssl_key={{ .TLSAdminKeyPath }} +admin_ssl_cert={{ .TLSAdminCertPath }} +admin_ssl_ca_cert={{ .TLSAdminCAPath }} +{{- end }} {{ range $key,$value := .Params }} {{- $key }}={{ $value }} {{ end }}`) @@ -56,7 +68,10 @@ admin_secure_gui={{ .AdminSecureGui }} AdminHost: configValueOrDefault("admin_host", mxs.Spec.Config.Params, "0.0.0.0"), AdminPort: mxs.Spec.Admin.Port, AdminGui: ptr.Deref(mxs.Spec.Admin.GuiEnabled, true), - AdminSecureGui: false, + TLSEnabled: mxs.IsTLSEnabled(), + TLSAdminKeyPath: builderpki.AdminKeyPath, + TLSAdminCertPath: builderpki.AdminCertPath, + TLSAdminCAPath: builderpki.CACertPath, Params: filterExistingConfig(mxs.Spec.Config.Params), }) if err != nil { diff --git a/pkg/maxscale/config/config_test.go b/pkg/maxscale/config/config_test.go index 236da550fd..bd1192c205 100644 --- a/pkg/maxscale/config/config_test.go +++ b/pkg/maxscale/config/config_test.go @@ -33,6 +33,31 @@ admin_host=0.0.0.0 admin_port=8989 admin_gui=true admin_secure_gui=false +`, + }, + { + name: "tls", + mxs: &mariadbv1alpha1.MaxScale{ + Spec: mariadbv1alpha1.MaxScaleSpec{ + Admin: mariadbv1alpha1.MaxScaleAdmin{ + Port: 8989, + }, + TLS: &mariadbv1alpha1.MaxScaleTLS{ + Enabled: true, + }, + }, + }, + wantConfig: `[maxscale] +threads=auto +persist_runtime_changes=true +load_persisted_configs=true +admin_host=0.0.0.0 +admin_port=8989 +admin_gui=true +admin_secure_gui=true +admin_ssl_key=/etc/pki/admin.key +admin_ssl_cert=/etc/pki/admin.crt +admin_ssl_ca_cert=/etc/pki/ca.crt `, }, { diff --git a/pkg/metadata/metadata.go b/pkg/metadata/metadata.go index 5417b1d8cc..ffb1030e49 100644 --- a/pkg/metadata/metadata.go +++ b/pkg/metadata/metadata.go @@ -3,9 +3,18 @@ package metadata var ( WatchLabel = "k8s.mariadb.com/watch" - ReplicationAnnotation = "k8s.mariadb.com/replication" - GaleraAnnotation = "k8s.mariadb.com/galera" - MariadbAnnotation = "k8s.mariadb.com/mariadb" - ConfigAnnotation = "k8s.mariadb.com/config" + ReplicationAnnotation = "k8s.mariadb.com/replication" + GaleraAnnotation = "k8s.mariadb.com/galera" + MariadbAnnotation = "k8s.mariadb.com/mariadb" + + ConfigAnnotation = "k8s.mariadb.com/config" + ConfigGaleraAnnotation = "k8s.mariadb.com/config-galera" + + TLSCAAnnotation = "k8s.mariadb.com/ca" + TLSServerCertAnnotation = "k8s.mariadb.com/server-cert" + TLSClientCertAnnotation = "k8s.mariadb.com/client-cert" + TLSAdminCertAnnotation = "k8s.mariadb.com/admin-cert" + TLSListenerCertAnnotation = "k8s.mariadb.com/listener-cert" + WebhookConfigAnnotation = "k8s.mariadb.com/webhook" ) diff --git a/pkg/minio/minio.go b/pkg/minio/minio.go index 5117e15f1e..3b0073d349 100644 --- a/pkg/minio/minio.go +++ b/pkg/minio/minio.go @@ -2,6 +2,7 @@ package minio import ( "crypto/x509" + "errors" "fmt" "net/http" "os" @@ -92,7 +93,7 @@ func getTransport(opts *MinioOpts) (*http.Transport, error) { return nil, fmt.Errorf("error reading CA cert: %v", err) } if ok := transport.TLSClientConfig.RootCAs.AppendCertsFromPEM(caBytes); !ok { - return nil, fmt.Errorf("error parsing CA cert : %s", err) + return nil, errors.New("unable to add CA cert to pool") } } diff --git a/pkg/pki/keypair.go b/pkg/pki/keypair.go new file mode 100644 index 0000000000..88dd21b647 --- /dev/null +++ b/pkg/pki/keypair.go @@ -0,0 +1,194 @@ +package pki + +import ( + "crypto" + "crypto/rand" + "crypto/tls" + "crypto/x509" + "errors" + "fmt" + + corev1 "k8s.io/api/core/v1" +) + +var ( + // CACertKey is the key used to store the CA certificate in a secret. + CACertKey = "ca.crt" + // CAKeyKey is the key used to store the CA private key in a secret. + CAKeyKey = "ca.key" + // TLSCertKey is the key used to store the TLS certificate in a secret. + TLSCertKey = "tls.crt" + // TLSKeyKey is the key used to store the TLS private key in a secret. + TLSKeyKey = "tls.key" +) + +// ErrSecretKeyNotFound is returned when a CA/TLS key is not found in a Secret- +var ErrSecretKeyNotFound = errors.New("Secret key not found") + +// KeyPairOpt is a function type used to configure a KeyPair. +type KeyPairOpt func(*KeyPair) + +// WithSupportedPrivateKeys returns a KeyPairOpt that sets the supported private keys for a KeyPair. +func WithSupportedPrivateKeys(pks ...PrivateKey) KeyPairOpt { + return func(k *KeyPair) { + k.SupportedPrivateKeys = pks + } +} + +// KeyPair represents a TLS key pair with its certificate and private key. +type KeyPair struct { + // CertPEM is the PEM-encoded certificate. + CertPEM []byte + // KeyPEM is the PEM-encoded private key. + KeyPEM []byte + // SupportedPrivateKeys is a list of supported private key types. + SupportedPrivateKeys []PrivateKey +} + +// NewKeyPair creates a new KeyPair with the given certificate and private key PEM data. +// Additional options can be provided to configure the KeyPair. +func NewKeyPair(certPEM, keyPEM []byte, opts ...KeyPairOpt) (*KeyPair, error) { + k := KeyPair{ + CertPEM: certPEM, + KeyPEM: keyPEM, + SupportedPrivateKeys: []PrivateKey{ + PrivateKeyTypeECDSA, + }, + } + for _, setOpt := range opts { + setOpt(&k) + } + if err := k.Validate(); err != nil { + return nil, fmt.Errorf("invalid keypair: %v", err) + } + return &k, nil +} + +// Validate checks if the KeyPair is valid by ensuring the certificate and private key are not empty +// and can be parsed correctly. +func (k *KeyPair) Validate() error { + if len(k.CertPEM) == 0 { + return errors.New("certificate PEM is empty") + } + if len(k.KeyPEM) == 0 { + return errors.New("private key PEM is empty") + } + if _, err := k.Certificates(); err != nil { + return fmt.Errorf("error parsing certificates: %v", err) + } + if len(k.SupportedPrivateKeys) <= 1 { + if _, err := k.PrivateKey(); err != nil { + return fmt.Errorf("error parsing private key: %v", err) + } + } + if _, err := tls.X509KeyPair(k.CertPEM, k.KeyPEM); err != nil { + return fmt.Errorf("invalid keypair: %v", err) + } + return nil +} + +// Certificates parses and returns the certificates from the CertPEM field. +func (k *KeyPair) Certificates() ([]*x509.Certificate, error) { + return ParseCertificates(k.CertPEM) +} + +// Certificates parses and returns the leaf certificate from the CertPEM field. +func (k *KeyPair) LeafCertificate() (*x509.Certificate, error) { + certs, err := k.Certificates() + if err != nil { + return nil, fmt.Errorf("error getting certs: %v", err) + } + return certs[0], nil // leaf certificate should be the first in the chain to establish trust +} + +// PrivateKey parses and returns the private key from the KeyPEM field. +func (k *KeyPair) PrivateKey() (crypto.Signer, error) { + return ParsePrivateKey(k.KeyPEM, k.SupportedPrivateKeys) +} + +// UpdateTLSSecret updates the given Kubernetes secret with the certificate and private key from the KeyPair. +func (k *KeyPair) UpdateSecret(secret *corev1.Secret, certKey, privateKeyKey string) { + if secret.Data == nil { + secret.Data = make(map[string][]byte) + } + secret.Data[certKey] = k.CertPEM + secret.Data[privateKeyKey] = k.KeyPEM +} + +// UpdateTLSSecret updates the given Kubernetes TLS secret with the certificate and private key from the KeyPair. +func (k *KeyPair) UpdateTLSSecret(secret *corev1.Secret) { + k.UpdateSecret(secret, TLSCertKey, TLSKeyKey) +} + +// UpdateTLSSecret updates the given Kubernetes CA secret with the certificate and private key from the KeyPair. +func (k *KeyPair) UpdateCASecret(secret *corev1.Secret) { + k.UpdateSecret(secret, CACertKey, CAKeyKey) +} + +// NewKeyPairFromTLSSecret creates a new KeyPair from the given Kubernetes secret. +func NewKeyPairFromSecret(secret *corev1.Secret, certKey, privateKeyKey string, opts ...KeyPairOpt) (*KeyPair, error) { + if secret.Data == nil { + return nil, errors.New("TLS Secret is empty") + } + certPEM, ok := secret.Data[certKey] + if !ok { + return nil, fmt.Errorf("certificate key \"%s\" not found: %w", certKey, ErrSecretKeyNotFound) + } + keyPEM, ok := secret.Data[privateKeyKey] + if !ok { + return nil, fmt.Errorf("private key key \"%s\" not found: %w", privateKeyKey, ErrSecretKeyNotFound) + } + return NewKeyPair(certPEM, keyPEM, opts...) +} + +// NewKeyPairFromTLSSecret creates a new KeyPair from the given Kubernetes TLS secret. +func NewKeyPairFromTLSSecret(secret *corev1.Secret, opts ...KeyPairOpt) (*KeyPair, error) { + return NewKeyPairFromSecret(secret, TLSCertKey, TLSKeyKey, opts...) +} + +// NewKeyPairFromTLSSecret creates a new KeyPair from the given Kubernetes CA secret. +func NewKeyPairFromCASecret(secret *corev1.Secret, opts ...KeyPairOpt) (*KeyPair, error) { + return NewKeyPairFromSecret(secret, CACertKey, CAKeyKey, opts...) +} + +// NewKeyPairFromTemplate creates a new KeyPair from the given certificate template and CA KeyPair. +// Additional options can be provided to configure the KeyPair. +func NewKeyPairFromTemplate(tpl *x509.Certificate, caKeyPair *KeyPair, opts ...KeyPairOpt) (*KeyPair, error) { + privateKey, err := GeneratePrivateKey() + if err != nil { + return nil, fmt.Errorf("error generating private key: %v", err) + } + + parentCert := tpl + parentKey := privateKey + if caKeyPair != nil { + caCerts, err := caKeyPair.Certificates() + if err != nil { + return nil, fmt.Errorf("error getting CA certificate: %v", err) + } + caPrivateKey, err := caKeyPair.PrivateKey() + if err != nil { + return nil, fmt.Errorf("error getting CA private key: %v", err) + } + + parentCert = caCerts[0] // assume first certificate in the CA bundle + parentKey = caPrivateKey + } + + certBytes, err := x509.CreateCertificate(rand.Reader, tpl, parentCert, privateKey.Public(), parentKey) + if err != nil { + return nil, fmt.Errorf("error creating certificate: %v", err) + } + privateKeyBytes, err := MarshalPrivateKey(privateKey) + if err != nil { + return nil, fmt.Errorf("error creating private key: %v", err) + } + + certPEMBytes := pemEncodeCertificate(certBytes) + privateKeyPEMBytes, err := pemEncodePrivateKey(privateKeyBytes, parentKey) + if err != nil { + return nil, fmt.Errorf("error encoding private key PEM: %v", err) + } + + return NewKeyPair(certPEMBytes, privateKeyPEMBytes, opts...) +} diff --git a/pkg/pki/keypair_test.go b/pkg/pki/keypair_test.go new file mode 100644 index 0000000000..0b78e6a45c --- /dev/null +++ b/pkg/pki/keypair_test.go @@ -0,0 +1,562 @@ +package pki + +import ( + "crypto/x509" + "crypto/x509/pkix" + "math/big" + "reflect" + "testing" + "time" + + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func TestNewKeyPair(t *testing.T) { + tests := []struct { + name string + certPEM []byte + keyPEM []byte + opts []KeyPairOpt + wantErr bool + }{ + { + name: "Empty Cert PEM", + certPEM: []byte(""), + keyPEM: []byte(`-----BEGIN EC PRIVATE KEY----- +MHcCAQEEIAdp3iKnNA1kO2Ep5Hw7owMcm06SecFGdOqW/vO4k2AjoAoGCCqGSM49 +AwEHoUQDQgAEiTVhkriBksuWW5W3Mv9L918m1BECaHUl7ZV/Pz2q84wY9aEbxe2P +J3c22DtEFzg9emNuruVS5/HL+hanzz4o+g== +-----END EC PRIVATE KEY----- +`), + opts: []KeyPairOpt{WithSupportedPrivateKeys(PrivateKeyTypeECDSA)}, + wantErr: true, + }, + { + name: "Empty Key PEM", + certPEM: []byte(`-----BEGIN CERTIFICATE----- +MIICXzCCAgagAwIBAgIRAIBgotjwHCDFrV2H9FWQrYIwCgYIKoZIzj0EAwIwNjEZ +MBcGA1UEChMQbWFyaWFkYi1vcGVyYXRvcjEZMBcGA1UEAxMQbWFyaWFkYi1vcGVy +YXRvcjAeFw0yNDEyMTkxNjI2NTVaFw0yNTEyMTkyMzI2NTVaMC8xLTArBgNVBAMT +JG1hcmlhZGItb3BlcmF0b3Itd2ViaG9vay5kZWZhdWx0LnN2YzBZMBMGByqGSM49 +AgEGCCqGSM49AwEHA0IABIk1YZK4gZLLlluVtzL/S/dfJtQRAmh1Je2Vfz89qvOM +GPWhG8Xtjyd3Ntg7RBc4PXpjbq7lUufxy/oWp88+KPqjgfswgfgwDgYDVR0PAQH/ +BAQDAgWgMBMGA1UdJQQMMAoGCCsGAQUFBwMBMAwGA1UdEwEB/wQCMAAwHwYDVR0j +BBgwFoAUXZzsgcecviPCXlVdrBw/tUEC2uYwgaEGA1UdEQSBmTCBloIybWFyaWFk +Yi1vcGVyYXRvci13ZWJob29rLmRlZmF1bHQuc3ZjLmNsdXN0ZXIubG9jYWyCJG1h +cmlhZGItb3BlcmF0b3Itd2ViaG9vay5kZWZhdWx0LnN2Y4IgbWFyaWFkYi1vcGVy +YXRvci13ZWJob29rLmRlZmF1bHSCGG1hcmlhZGItb3BlcmF0b3Itd2ViaG9vazAK +BggqhkjOPQQDAgNHADBEAiBSWY1rVufSE+3i0w553uJGJCC4Fpa6cvRPEti8X3Kp +1AIgG0qN5IT9EsRZaY4J2vBYsbN5LL+qRI5N0XGYqVWXuD8= +-----END CERTIFICATE----- +`), + keyPEM: []byte(""), + opts: []KeyPairOpt{WithSupportedPrivateKeys(PrivateKeyTypeECDSA)}, + wantErr: true, + }, + { + name: "Invalid Cert PEM", + certPEM: []byte("invalid-cert"), + keyPEM: []byte(`-----BEGIN EC PRIVATE KEY----- +MHcCAQEEIAdp3iKnNA1kO2Ep5Hw7owMcm06SecFGdOqW/vO4k2AjoAoGCCqGSM49 +AwEHoUQDQgAEiTVhkriBksuWW5W3Mv9L918m1BECaHUl7ZV/Pz2q84wY9aEbxe2P +J3c22DtEFzg9emNuruVS5/HL+hanzz4o+g== +-----END EC PRIVATE KEY----- +`), + opts: []KeyPairOpt{WithSupportedPrivateKeys(PrivateKeyTypeECDSA)}, + wantErr: true, + }, + { + name: "Invalid Key PEM", + certPEM: []byte(`-----BEGIN CERTIFICATE----- +MIICXzCCAgagAwIBAgIRAIBgotjwHCDFrV2H9FWQrYIwCgYIKoZIzj0EAwIwNjEZ +MBcGA1UEChMQbWFyaWFkYi1vcGVyYXRvcjEZMBcGA1UEAxMQbWFyaWFkYi1vcGVy +YXRvcjAeFw0yNDEyMTkxNjI2NTVaFw0yNTEyMTkyMzI2NTVaMC8xLTArBgNVBAMT +JG1hcmlhZGItb3BlcmF0b3Itd2ViaG9vay5kZWZhdWx0LnN2YzBZMBMGByqGSM49 +AgEGCCqGSM49AwEHA0IABIk1YZK4gZLLlluVtzL/S/dfJtQRAmh1Je2Vfz89qvOM +GPWhG8Xtjyd3Ntg7RBc4PXpjbq7lUufxy/oWp88+KPqjgfswgfgwDgYDVR0PAQH/ +BAQDAgWgMBMGA1UdJQQMMAoGCCsGAQUFBwMBMAwGA1UdEwEB/wQCMAAwHwYDVR0j +BBgwFoAUXZzsgcecviPCXlVdrBw/tUEC2uYwgaEGA1UdEQSBmTCBloIybWFyaWFk +Yi1vcGVyYXRvci13ZWJob29rLmRlZmF1bHQuc3ZjLmNsdXN0ZXIubG9jYWyCJG1h +cmlhZGItb3BlcmF0b3Itd2ViaG9vay5kZWZhdWx0LnN2Y4IgbWFyaWFkYi1vcGVy +YXRvci13ZWJob29rLmRlZmF1bHSCGG1hcmlhZGItb3BlcmF0b3Itd2ViaG9vazAK +BggqhkjOPQQDAgNHADBEAiBSWY1rVufSE+3i0w553uJGJCC4Fpa6cvRPEti8X3Kp +1AIgG0qN5IT9EsRZaY4J2vBYsbN5LL+qRI5N0XGYqVWXuD8= +-----END CERTIFICATE----- +`), + keyPEM: []byte("invalid-key"), + opts: []KeyPairOpt{WithSupportedPrivateKeys(PrivateKeyTypeRSA)}, + wantErr: true, + }, + { + name: "Valid", + certPEM: []byte(`-----BEGIN CERTIFICATE----- +MIICXzCCAgagAwIBAgIRAIBgotjwHCDFrV2H9FWQrYIwCgYIKoZIzj0EAwIwNjEZ +MBcGA1UEChMQbWFyaWFkYi1vcGVyYXRvcjEZMBcGA1UEAxMQbWFyaWFkYi1vcGVy +YXRvcjAeFw0yNDEyMTkxNjI2NTVaFw0yNTEyMTkyMzI2NTVaMC8xLTArBgNVBAMT +JG1hcmlhZGItb3BlcmF0b3Itd2ViaG9vay5kZWZhdWx0LnN2YzBZMBMGByqGSM49 +AgEGCCqGSM49AwEHA0IABIk1YZK4gZLLlluVtzL/S/dfJtQRAmh1Je2Vfz89qvOM +GPWhG8Xtjyd3Ntg7RBc4PXpjbq7lUufxy/oWp88+KPqjgfswgfgwDgYDVR0PAQH/ +BAQDAgWgMBMGA1UdJQQMMAoGCCsGAQUFBwMBMAwGA1UdEwEB/wQCMAAwHwYDVR0j +BBgwFoAUXZzsgcecviPCXlVdrBw/tUEC2uYwgaEGA1UdEQSBmTCBloIybWFyaWFk +Yi1vcGVyYXRvci13ZWJob29rLmRlZmF1bHQuc3ZjLmNsdXN0ZXIubG9jYWyCJG1h +cmlhZGItb3BlcmF0b3Itd2ViaG9vay5kZWZhdWx0LnN2Y4IgbWFyaWFkYi1vcGVy +YXRvci13ZWJob29rLmRlZmF1bHSCGG1hcmlhZGItb3BlcmF0b3Itd2ViaG9vazAK +BggqhkjOPQQDAgNHADBEAiBSWY1rVufSE+3i0w553uJGJCC4Fpa6cvRPEti8X3Kp +1AIgG0qN5IT9EsRZaY4J2vBYsbN5LL+qRI5N0XGYqVWXuD8= +-----END CERTIFICATE----- +`), + keyPEM: []byte(`-----BEGIN EC PRIVATE KEY----- +MHcCAQEEIAdp3iKnNA1kO2Ep5Hw7owMcm06SecFGdOqW/vO4k2AjoAoGCCqGSM49 +AwEHoUQDQgAEiTVhkriBksuWW5W3Mv9L918m1BECaHUl7ZV/Pz2q84wY9aEbxe2P +J3c22DtEFzg9emNuruVS5/HL+hanzz4o+g== +-----END EC PRIVATE KEY----- +`), + opts: []KeyPairOpt{WithSupportedPrivateKeys(PrivateKeyTypeECDSA)}, + wantErr: false, + }, + { + name: "Unmatched", + certPEM: []byte(`-----BEGIN CERTIFICATE----- +MIICXzCCAgagAwIBAgIRAIBgotjwHCDFrV2H9FWQrYIwCgYIKoZIzj0EAwIwNjEZ +MBcGA1UEChMQbWFyaWFkYi1vcGVyYXRvcjEZMBcGA1UEAxMQbWFyaWFkYi1vcGVy +YXRvcjAeFw0yNDEyMTkxNjI2NTVaFw0yNTEyMTkyMzI2NTVaMC8xLTArBgNVBAMT +JG1hcmlhZGItb3BlcmF0b3Itd2ViaG9vay5kZWZhdWx0LnN2YzBZMBMGByqGSM49 +AgEGCCqGSM49AwEHA0IABIk1YZK4gZLLlluVtzL/S/dfJtQRAmh1Je2Vfz89qvOM +GPWhG8Xtjyd3Ntg7RBc4PXpjbq7lUufxy/oWp88+KPqjgfswgfgwDgYDVR0PAQH/ +BAQDAgWgMBMGA1UdJQQMMAoGCCsGAQUFBwMBMAwGA1UdEwEB/wQCMAAwHwYDVR0j +BBgwFoAUXZzsgcecviPCXlVdrBw/tUEC2uYwgaEGA1UdEQSBmTCBloIybWFyaWFk +Yi1vcGVyYXRvci13ZWJob29rLmRlZmF1bHQuc3ZjLmNsdXN0ZXIubG9jYWyCJG1h +cmlhZGItb3BlcmF0b3Itd2ViaG9vay5kZWZhdWx0LnN2Y4IgbWFyaWFkYi1vcGVy +YXRvci13ZWJob29rLmRlZmF1bHSCGG1hcmlhZGItb3BlcmF0b3Itd2ViaG9vazAK +BggqhkjOPQQDAgNHADBEAiBSWY1rVufSE+3i0w553uJGJCC4Fpa6cvRPEti8X3Kp +1AIgG0qN5IT9EsRZaY4J2vBYsbN5LL+qRI5N0XGYqVWXuD8= +-----END CERTIFICATE----- +`), + keyPEM: []byte(`-----BEGIN RSA PRIVATE KEY----- +MIIEogIBAAKCAQEA1l202NMTln0/ngg4JXUJLJXvhSjjHimO22c47tHhWvnzhtnK +CrH8cWBnnxO11os5PcNIUYTxn04mZPRs+p1YkE9DMlp9Lgy/38304rr4kjllVspv +l9MdrelqbcDy520rgF/YObfMZvzeseH2F5UK386IXb1KYSmp8dn7RU2HvUf17Z/z +1ScdvOS4xXPNjuAi28REA72vPbFwLbt+mQxBQ/Aal6BNH5RhNIOZ9m8fVsWn/e/4 +hZTa2Ib/pp/3j2D1UlJqBiAh4cBeI0QYbj/hN5+OpVUJA3+OsGzFOBhs7KfqMAP3 +KDTt7sTrPV03QKKqhDjh3LIzdZyEWHPMJesMawIDAQABAoIBAEw5tf0D0YtJrj17 +nrtzCngYOLuY9mnbTTknU09YwlGfX8Er4HQ9Jg8KwM4ILDjF+OzFbAnQxDpph62O +XNIg8UUfaj2Vf73IOtJSYindYlZconRiN5w9LeiRf47XdYhlgXp8ml6rxLs6X9XR +C7kG/n7m6garMK+sKQofAQJ7tzDOpzna/V+C3z7vwupmypjVmKuqFfDOITUhk06Q +WNUbiBSRbVtuWJ4+xpg5cZbCSIzlziQFbaRjQ0bHVIoJ/DrHI8OCEELjcQi7aMPX +Z6XZnpVteae5o5lpw1AbB7c4ALF5B4Z1RQKakb+mK7o4F6SV/TOf2Fb7Gwp5gSOO +Udo/acECgYEA7woaxJvkyJH6G83kt73RUudFbKIUo/lv8E0BLcN/7jo8zVMQIoxN +m6fffVWfy2zVd5rLus4wjq+f5jmAprJykkA7n3HIM6HJ2v9KJPVCBKA8bgJ5eU0Y +c7VFz8O0Z8dj/p9oVrHLo0Aqo+69rXG6wcyck1au3FEw16jflU1MaMUCgYEA5ZNv +fHHDtWDjCPM75N7aaw2C9ENL3fi8Fwh/h4ZRj/BftHnHZkjY3bpW1/17lXuTnbE6 +uHeq3s/wBt4+F/N61ps1a7NTOtZHst5fPZZUjWuT0vq0EN2KB81iR/N4ld7w3F3v +bZSPNXpm1J3wSAVrL1tHdQdWXdkOTeTA4wAnE28CgYANZKuLSJDRDBzPYgHmqaQI +2RxyscImTduPw0DFp6aLWof9mSHWTbYreoRzKVECvN5ZDTtNBDCETiLPa3lh3a29 +tAujK2TkP7RnqNYmq/c++xtnrovP2Bn+obF/qp95ERrxMU1PTjbytq2s8bt+9Fha +c3RybPDvNz1dWADvBJ27YQKBgAF6b49XlDEIzK10E4CnxrRFxAAaptRpE5z6Wwfe +X4wTuioJVrVb5rmWx5Rgd3lA8HRlfcFOU/VXVW5V5AR3duUG3tMwtmp8kr2eHPLi +kuzOMod7QcmSA5+FPQrFkJM2ekqQ+Ee2Wy22+g6IbdGo50XIyq8AOxgjm6n4vR05 +FQdVAoGAUt0hi789SX+BKzkWWMnds8HZzO3a5OgN9on+Hd/BVaQG6+F4wBGyjdjz +PEZhWvsvx9Qge+DhwW3vfTDH2RItevD5Av4x0jZX0TWPoII8aP07VmDQ4g0cEkKh +nBZoGLkeDofSc+Ml4HRpi43U+fqhU77wr8Gq0YU74h7lFfiRI/M= +-----END RSA PRIVATE KEY----- +`), + opts: []KeyPairOpt{WithSupportedPrivateKeys(PrivateKeyTypeRSA)}, + wantErr: true, + }, + { + name: "Unsupported", + certPEM: []byte(`-----BEGIN CERTIFICATE----- +MIICXzCCAgagAwIBAgIRAIBgotjwHCDFrV2H9FWQrYIwCgYIKoZIzj0EAwIwNjEZ +MBcGA1UEChMQbWFyaWFkYi1vcGVyYXRvcjEZMBcGA1UEAxMQbWFyaWFkYi1vcGVy +YXRvcjAeFw0yNDEyMTkxNjI2NTVaFw0yNTEyMTkyMzI2NTVaMC8xLTArBgNVBAMT +JG1hcmlhZGItb3BlcmF0b3Itd2ViaG9vay5kZWZhdWx0LnN2YzBZMBMGByqGSM49 +AgEGCCqGSM49AwEHA0IABIk1YZK4gZLLlluVtzL/S/dfJtQRAmh1Je2Vfz89qvOM +GPWhG8Xtjyd3Ntg7RBc4PXpjbq7lUufxy/oWp88+KPqjgfswgfgwDgYDVR0PAQH/ +BAQDAgWgMBMGA1UdJQQMMAoGCCsGAQUFBwMBMAwGA1UdEwEB/wQCMAAwHwYDVR0j +BBgwFoAUXZzsgcecviPCXlVdrBw/tUEC2uYwgaEGA1UdEQSBmTCBloIybWFyaWFk +Yi1vcGVyYXRvci13ZWJob29rLmRlZmF1bHQuc3ZjLmNsdXN0ZXIubG9jYWyCJG1h +cmlhZGItb3BlcmF0b3Itd2ViaG9vay5kZWZhdWx0LnN2Y4IgbWFyaWFkYi1vcGVy +YXRvci13ZWJob29rLmRlZmF1bHSCGG1hcmlhZGItb3BlcmF0b3Itd2ViaG9vazAK +BggqhkjOPQQDAgNHADBEAiBSWY1rVufSE+3i0w553uJGJCC4Fpa6cvRPEti8X3Kp +1AIgG0qN5IT9EsRZaY4J2vBYsbN5LL+qRI5N0XGYqVWXuD8= +-----END CERTIFICATE----- +`), + keyPEM: []byte(`-----BEGIN EC PRIVATE KEY----- +MHcCAQEEIAdp3iKnNA1kO2Ep5Hw7owMcm06SecFGdOqW/vO4k2AjoAoGCCqGSM49 +AwEHoUQDQgAEiTVhkriBksuWW5W3Mv9L918m1BECaHUl7ZV/Pz2q84wY9aEbxe2P +J3c22DtEFzg9emNuruVS5/HL+hanzz4o+g== +-----END EC PRIVATE KEY----- +`), + opts: []KeyPairOpt{WithSupportedPrivateKeys(PrivateKeyTypeRSA)}, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := NewKeyPair(tt.certPEM, tt.keyPEM, tt.opts...) + if (err != nil) != tt.wantErr { + t.Errorf("NewKeyPair() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + +func TestNewKeyPairFromSecret(t *testing.T) { + tests := []struct { + name string + secret *corev1.Secret + certKey string + privateKeyKey string + opts []KeyPairOpt + wantErr bool + }{ + { + name: "Empty Secret", + secret: &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test", + }, + Data: map[string][]byte{}, + }, + certKey: TLSCertKey, + privateKeyKey: TLSKeyKey, + wantErr: true, + }, + { + name: "Missing Cert Key", + secret: &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test", + }, + Data: map[string][]byte{ + TLSKeyKey: []byte(`-----BEGIN EC PRIVATE KEY----- +MHcCAQEEIAdp3iKnNA1kO2Ep5Hw7owMcm06SecFGdOqW/vO4k2AjoAoGCCqGSM49 +AwEHoUQDQgAEiTVhkriBksuWW5W3Mv9L918m1BECaHUl7ZV/Pz2q84wY9aEbxe2P +J3c22DtEFzg9emNuruVS5/HL+hanzz4o+g== +-----END EC PRIVATE KEY----- +`), + }, + }, + certKey: TLSCertKey, + privateKeyKey: TLSKeyKey, + wantErr: true, + }, + { + name: "Missing Key Key", + secret: &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test", + }, + Data: map[string][]byte{ + TLSCertKey: []byte(`-----BEGIN CERTIFICATE----- +MIICXzCCAgagAwIBAgIRAIBgotjwHCDFrV2H9FWQrYIwCgYIKoZIzj0EAwIwNjEZ +MBcGA1UEChMQbWFyaWFkYi1vcGVyYXRvcjEZMBcGA1UEAxMQbWFyaWFkYi1vcGVy +YXRvcjAeFw0yNDEyMTkxNjI2NTVaFw0yNTEyMTkyMzI2NTVaMC8xLTArBgNVBAMT +JG1hcmlhZGItb3BlcmF0b3Itd2ViaG9vay5kZWZhdWx0LnN2YzBZMBMGByqGSM49 +AgEGCCqGSM49AwEHA0IABIk1YZK4gZLLlluVtzL/S/dfJtQRAmh1Je2Vfz89qvOM +GPWhG8Xtjyd3Ntg7RBc4PXpjbq7lUufxy/oWp88+KPqjgfswgfgwDgYDVR0PAQH/ +BAQDAgWgMBMGA1UdJQQMMAoGCCsGAQUFBwMBMAwGA1UdEwEB/wQCMAAwHwYDVR0j +BBgwFoAUXZzsgcecviPCXlVdrBw/tUEC2uYwgaEGA1UdEQSBmTCBloIybWFyaWFk +Yi1vcGVyYXRvci13ZWJob29rLmRlZmF1bHQuc3ZjLmNsdXN0ZXIubG9jYWyCJG1h +cmlhZGItb3BlcmF0b3Itd2ViaG9vay5kZWZhdWx0LnN2Y4IgbWFyaWFkYi1vcGVy +YXRvci13ZWJob29rLmRlZmF1bHSCGG1hcmlhZGItb3BlcmF0b3Itd2ViaG9vazAK +BggqhkjOPQQDAgNHADBEAiBSWY1rVufSE+3i0w553uJGJCC4Fpa6cvRPEti8X3Kp +1AIgG0qN5IT9EsRZaY4J2vBYsbN5LL+qRI5N0XGYqVWXuD8= +-----END CERTIFICATE----- +`), + }, + }, + certKey: TLSCertKey, + privateKeyKey: TLSKeyKey, + wantErr: true, + }, + { + name: "Invalid Cert PEM", + secret: &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test", + }, + Data: map[string][]byte{ + TLSCertKey: []byte("invalid-cert"), + TLSKeyKey: []byte(`-----BEGIN EC PRIVATE KEY----- +MHcCAQEEIAdp3iKnNA1kO2Ep5Hw7owMcm06SecFGdOqW/vO4k2AjoAoGCCqGSM49 +AwEHoUQDQgAEiTVhkriBksuWW5W3Mv9L918m1BECaHUl7ZV/Pz2q84wY9aEbxe2P +J3c22DtEFzg9emNuruVS5/HL+hanzz4o+g== +-----END EC PRIVATE KEY----- +`), + }, + }, + certKey: TLSCertKey, + privateKeyKey: TLSKeyKey, + wantErr: true, + }, + { + name: "Invalid Key PEM", + secret: &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test", + }, + Data: map[string][]byte{ + TLSCertKey: []byte(`-----BEGIN CERTIFICATE----- +MIICXzCCAgagAwIBAgIRAIBgotjwHCDFrV2H9FWQrYIwCgYIKoZIzj0EAwIwNjEZ +MBcGA1UEChMQbWFyaWFkYi1vcGVyYXRvcjEZMBcGA1UEAxMQbWFyaWFkYi1vcGVy +YXRvcjAeFw0yNDEyMTkxNjI2NTVaFw0yNTEyMTkyMzI2NTVaMC8xLTArBgNVBAMT +JG1hcmlhZGItb3BlcmF0b3Itd2ViaG9vay5kZWZhdWx0LnN2YzBZMBMGByqGSM49 +AgEGCCqGSM49AwEHA0IABIk1YZK4gZLLlluVtzL/S/dfJtQRAmh1Je2Vfz89qvOM +GPWhG8Xtjyd3Ntg7RBc4PXpjbq7lUufxy/oWp88+KPqjgfswgfgwDgYDVR0PAQH/ +BAQDAgWgMBMGA1UdJQQMMAoGCCsGAQUFBwMBMAwGA1UdEwEB/wQCMAAwHwYDVR0j +BBgwFoAUXZzsgcecviPCXlVdrBw/tUEC2uYwgaEGA1UdEQSBmTCBloIybWFyaWFk +Yi1vcGVyYXRvci13ZWJob29rLmRlZmF1bHQuc3ZjLmNsdXN0ZXIubG9jYWyCJG1h +cmlhZGItb3BlcmF0b3Itd2ViaG9vay5kZWZhdWx0LnN2Y4IgbWFyaWFkYi1vcGVy +YXRvci13ZWJob29rLmRlZmF1bHSCGG1hcmlhZGItb3BlcmF0b3Itd2ViaG9vazAK +BggqhkjOPQQDAgNHADBEAiBSWY1rVufSE+3i0w553uJGJCC4Fpa6cvRPEti8X3Kp +1AIgG0qN5IT9EsRZaY4J2vBYsbN5LL+qRI5N0XGYqVWXuD8= +-----END CERTIFICATE----- +`), + TLSKeyKey: []byte("invalid-key"), + }, + }, + certKey: TLSCertKey, + privateKeyKey: TLSKeyKey, + wantErr: true, + }, + { + name: "Valid", + secret: &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test", + }, + Data: map[string][]byte{ + TLSCertKey: []byte(`-----BEGIN CERTIFICATE----- +MIICXzCCAgagAwIBAgIRAIBgotjwHCDFrV2H9FWQrYIwCgYIKoZIzj0EAwIwNjEZ +MBcGA1UEChMQbWFyaWFkYi1vcGVyYXRvcjEZMBcGA1UEAxMQbWFyaWFkYi1vcGVy +YXRvcjAeFw0yNDEyMTkxNjI2NTVaFw0yNTEyMTkyMzI2NTVaMC8xLTArBgNVBAMT +JG1hcmlhZGItb3BlcmF0b3Itd2ViaG9vay5kZWZhdWx0LnN2YzBZMBMGByqGSM49 +AgEGCCqGSM49AwEHA0IABIk1YZK4gZLLlluVtzL/S/dfJtQRAmh1Je2Vfz89qvOM +GPWhG8Xtjyd3Ntg7RBc4PXpjbq7lUufxy/oWp88+KPqjgfswgfgwDgYDVR0PAQH/ +BAQDAgWgMBMGA1UdJQQMMAoGCCsGAQUFBwMBMAwGA1UdEwEB/wQCMAAwHwYDVR0j +BBgwFoAUXZzsgcecviPCXlVdrBw/tUEC2uYwgaEGA1UdEQSBmTCBloIybWFyaWFk +Yi1vcGVyYXRvci13ZWJob29rLmRlZmF1bHQuc3ZjLmNsdXN0ZXIubG9jYWyCJG1h +cmlhZGItb3BlcmF0b3Itd2ViaG9vay5kZWZhdWx0LnN2Y4IgbWFyaWFkYi1vcGVy +YXRvci13ZWJob29rLmRlZmF1bHSCGG1hcmlhZGItb3BlcmF0b3Itd2ViaG9vazAK +BggqhkjOPQQDAgNHADBEAiBSWY1rVufSE+3i0w553uJGJCC4Fpa6cvRPEti8X3Kp +1AIgG0qN5IT9EsRZaY4J2vBYsbN5LL+qRI5N0XGYqVWXuD8= +-----END CERTIFICATE----- +`), + TLSKeyKey: []byte(`-----BEGIN EC PRIVATE KEY----- +MHcCAQEEIAdp3iKnNA1kO2Ep5Hw7owMcm06SecFGdOqW/vO4k2AjoAoGCCqGSM49 +AwEHoUQDQgAEiTVhkriBksuWW5W3Mv9L918m1BECaHUl7ZV/Pz2q84wY9aEbxe2P +J3c22DtEFzg9emNuruVS5/HL+hanzz4o+g== +-----END EC PRIVATE KEY----- +`), + }, + }, + certKey: TLSCertKey, + privateKeyKey: TLSKeyKey, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := NewKeyPairFromSecret(tt.secret, tt.certKey, tt.privateKeyKey, tt.opts...) + if (err != nil) != tt.wantErr { + t.Errorf("NewKeyPairFromSecret() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + +func TestNewKeyPairFromTemplate(t *testing.T) { + // Subject: CN=mariadb-operator + caCertPEm := []byte(`-----BEGIN CERTIFICATE----- +MIIByjCCAW+gAwIBAgIQInuHavw36CvWL7osylp/9jAKBggqhkjOPQQDAjA2MRkw +FwYDVQQKExBtYXJpYWRiLW9wZXJhdG9yMRkwFwYDVQQDExBtYXJpYWRiLW9wZXJh +dG9yMB4XDTI0MTIxOTE2MjY1NVoXDTI4MTIxOTE3MjY1NVowNjEZMBcGA1UEChMQ +bWFyaWFkYi1vcGVyYXRvcjEZMBcGA1UEAxMQbWFyaWFkYi1vcGVyYXRvcjBZMBMG +ByqGSM49AgEGCCqGSM49AwEHA0IABIx2WSuRfc98PRJB8+7IkFjLBh0jqdQXWwLt +HW2tYw+MrFJthf93kcDH122iAUsjZ0/nvf4JR0LFJmFS7uTLJJijXzBdMA4GA1Ud +DwEB/wQEAwICpDAPBgNVHRMBAf8EBTADAQH/MB0GA1UdDgQWBBRdnOyBx5y+I8Je +VV2sHD+1QQLa5jAbBgNVHREEFDASghBtYXJpYWRiLW9wZXJhdG9yMAoGCCqGSM49 +BAMCA0kAMEYCIQClC63wr9jTwqhd8DKuKN5riMgoW4vXxvbsBfKuoSPvdAIhALSx +2Ky+mUAol0I2FdkaeUr3r5AObHsr5cH4OdFszF6K +-----END CERTIFICATE----- +`) + caPrivateKeyPEM := []byte(`-----BEGIN EC PRIVATE KEY----- +MHcCAQEEIOfniC62mKKWIuszxytrnSOZhSZNYhdisDwFcOFOXrAxoAoGCCqGSM49 +AwEHoUQDQgAEjHZZK5F9z3w9EkHz7siQWMsGHSOp1BdbAu0dba1jD4ysUm2F/3eR +wMfXbaIBSyNnT+e9/glHQsUmYVLu5MskmA== +-----END EC PRIVATE KEY----- +`) + caKeyPair, err := NewKeyPair(caCertPEm, caPrivateKeyPEM) + if err != nil { + t.Fatalf("unexpected error generating CA keypair: %v", err) + } + + tests := []struct { + name string + tpl *x509.Certificate + caKeyPair *KeyPair + opts []KeyPairOpt + wantErr bool + wantCommonName string + wantIssuer string + }{ + { + name: "Invalid CA", + tpl: &x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{ + CommonName: "cert", + }, + NotBefore: time.Now(), + NotAfter: time.Now().Add(DefaultCertLifetime), + KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment, + BasicConstraintsValid: true, + }, + caKeyPair: &KeyPair{ + CertPEM: []byte("invalid-cert"), + KeyPEM: []byte("invalid-key"), + }, + wantErr: true, + wantCommonName: "", + wantIssuer: "", + }, + { + name: "Self-signed CA", + tpl: &x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{ + CommonName: "ca", + }, + NotBefore: time.Now(), + NotAfter: time.Now().Add(-DefaultCertLifetime), // Invalid NotAfter + KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment | x509.KeyUsageCertSign, + BasicConstraintsValid: true, + IsCA: true, + }, + caKeyPair: nil, + wantErr: false, + wantCommonName: "ca", + wantIssuer: "ca", + }, + { + name: "Leaf certificate", + tpl: &x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{ + CommonName: "cert", + }, + NotBefore: time.Now(), + NotAfter: time.Now().Add(DefaultCertLifetime), + KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment, + BasicConstraintsValid: true, + }, + caKeyPair: caKeyPair, + wantErr: false, + wantCommonName: "cert", + wantIssuer: "mariadb-operator", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + keyPair, err := NewKeyPairFromTemplate(tt.tpl, tt.caKeyPair) + if (err != nil) != tt.wantErr { + t.Errorf("NewKeyPairFromTemplate() error = %v, wantErr %v", err, tt.wantErr) + } + if tt.wantErr { + return + } + + cert, err := keyPair.LeafCertificate() + if err != nil { + t.Errorf("error getting leaf certificate: %v", err) + } + + if cert.Subject.CommonName != tt.wantCommonName { + t.Errorf("CommonName = %v, want %v", cert.Subject.CommonName, tt.wantCommonName) + } + if cert.Issuer.CommonName != tt.wantIssuer { + t.Errorf("Issuer = %v, want %v", cert.Issuer.CommonName, tt.wantIssuer) + } + }) + } +} + +func TestUpdateSecret(t *testing.T) { + tests := []struct { + name string + keyPair *KeyPair + secret *corev1.Secret + certKey string + privateKeyKey string + want map[string][]byte + }{ + { + name: "Update empty secret", + keyPair: &KeyPair{ + CertPEM: []byte("cert"), + KeyPEM: []byte("key"), + }, + secret: &corev1.Secret{ + Data: map[string][]byte{}, + }, + certKey: TLSCertKey, + privateKeyKey: TLSKeyKey, + want: map[string][]byte{ + TLSCertKey: []byte("cert"), + TLSKeyKey: []byte("key"), + }, + }, + { + name: "Update existing secret", + keyPair: &KeyPair{ + CertPEM: []byte("new-cert"), + KeyPEM: []byte("new-key"), + }, + secret: &corev1.Secret{ + Data: map[string][]byte{ + TLSCertKey: []byte("old-cert"), + TLSKeyKey: []byte("old-key"), + }, + }, + certKey: TLSCertKey, + privateKeyKey: TLSKeyKey, + want: map[string][]byte{ + TLSCertKey: []byte("new-cert"), + TLSKeyKey: []byte("new-key"), + }, + }, + { + name: "Update secret with other data", + keyPair: &KeyPair{ + CertPEM: []byte("another-cert"), + KeyPEM: []byte("another-key"), + }, + secret: &corev1.Secret{ + Data: map[string][]byte{ + "other-key": []byte("other-value"), + }, + }, + certKey: TLSCertKey, + privateKeyKey: TLSKeyKey, + want: map[string][]byte{ + TLSCertKey: []byte("another-cert"), + TLSKeyKey: []byte("another-key"), + "other-key": []byte("other-value"), + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tt.keyPair.UpdateSecret(tt.secret, tt.certKey, tt.privateKeyKey) + if !reflect.DeepEqual(tt.secret.Data, tt.want) { + t.Errorf("UpdateSecret() = %v, want %v", tt.secret.Data, tt.want) + } + }) + } +} diff --git a/pkg/pki/pem.go b/pkg/pki/pem.go new file mode 100644 index 0000000000..01eaf8a124 --- /dev/null +++ b/pkg/pki/pem.go @@ -0,0 +1,129 @@ +package pki + +import ( + "crypto" + "crypto/ecdsa" + "crypto/rsa" + "crypto/x509" + "encoding/pem" + "errors" + "fmt" + "time" + + "github.com/go-logr/logr" +) + +const ( + pemBlockCertificate = "CERTIFICATE" + pemBlockECPrivateKey = "EC PRIVATE KEY" + pemBlockRSAPrivateKey = "RSA PRIVATE KEY" +) + +// BundleOption represents a function that applies a bundle configuration. +type BundleOption func(opts *BundleOptions) + +// WithLogger sets the logger option. +func WithLogger(logger logr.Logger) BundleOption { + return func(opts *BundleOptions) { + opts.logger = logger + } +} + +// WithSkipExpired sets an option to skip expired certs. +func WithSkipExpired(skipExpired bool) BundleOption { + return func(opts *BundleOptions) { + opts.skipExpired = skipExpired + } +} + +// BundleOptions represents options for bundling certificates. +type BundleOptions struct { + logger logr.Logger + skipExpired bool +} + +// BundleCertificatePEMs bundles multiple PEM-encoded certificate slices into a single bundle. +func BundleCertificatePEMs(pems [][]byte, bundleOpts ...BundleOption) ([]byte, error) { + opts := BundleOptions{ + logger: logr.Discard(), + skipExpired: false, + } + for _, opt := range bundleOpts { + opt(&opts) + } + + var bundle []byte + var err error + existingCerts := make(map[string]struct{}) + + for _, pem := range pems { + bundle, err = appendPEM(bundle, pem, existingCerts, opts) + if err != nil { + return nil, fmt.Errorf("error appending PEM: %v", err) + } + } + if bundle == nil { + return nil, errors.New("No certificate PEMs were found") + } + return bundle, nil +} + +func appendPEM(bundle []byte, pemBytes []byte, existingCerts map[string]struct{}, opts BundleOptions) ([]byte, error) { + var block *pem.Block + for len(pemBytes) > 0 { + block, pemBytes = pem.Decode(pemBytes) + if block == nil { + opts.logger.Error(errors.New("Invalid PEM block"), "Error decoding PEM block. Ignoring...") + break + } + if block.Type != string(pemBlockCertificate) { + return nil, fmt.Errorf("invalid PEM certificate block, got block type: %v", block.Type) + } + + cert, err := x509.ParseCertificate(block.Bytes) + if err != nil { + return nil, fmt.Errorf("invalid certificate in PEM block: %v", err) + } + certID := getCertID(cert) + + if _, ok := existingCerts[certID]; ok { + opts.logger.V(1).Info("skipping existing certificate", "cert-id", certID) + continue + } + + now := time.Now() + isExpired := now.Before(cert.NotBefore) || now.After(cert.NotAfter) + if opts.skipExpired && isExpired { + opts.logger.Info("skipping expired certificate", "cert-id", certID, "not-before", cert.NotBefore, "not-after", cert.NotAfter) + continue + } + + existingCerts[certID] = struct{}{} + bundle = append(bundle, pem.EncodeToMemory(block)...) + } + return bundle, nil +} + +func getCertID(cert *x509.Certificate) string { + if cert.SerialNumber != nil { + return fmt.Sprintf("%s-%s", cert.Subject.CommonName, cert.SerialNumber) + } + return cert.Subject.CommonName +} + +func pemEncodeCertificate(bytes []byte) []byte { + return pem.EncodeToMemory(&pem.Block{Type: pemBlockCertificate, Bytes: bytes}) +} + +func pemEncodePrivateKey(bytes []byte, signer crypto.Signer) ([]byte, error) { + var blockType string + switch signer.(type) { + case *ecdsa.PrivateKey: + blockType = pemBlockECPrivateKey + case *rsa.PrivateKey: + blockType = pemBlockRSAPrivateKey + default: + return nil, fmt.Errorf("unsupported private key: %t", signer) + } + return pem.EncodeToMemory(&pem.Block{Type: blockType, Bytes: bytes}), nil +} diff --git a/pkg/pki/pem_test.go b/pkg/pki/pem_test.go new file mode 100644 index 0000000000..5b938f14c1 --- /dev/null +++ b/pkg/pki/pem_test.go @@ -0,0 +1,516 @@ +package pki + +import ( + "testing" + + "github.com/google/go-cmp/cmp" +) + +func TestBundleCertificatePEMs(t *testing.T) { + tests := []struct { + name string + pems [][]byte + opts []BundleOption + wantBytes []byte + wantErr bool + }{ + { + name: "nil", + pems: nil, + opts: nil, + wantBytes: nil, + wantErr: true, + }, + { + name: "empty", + pems: [][]byte{ + []byte(""), + []byte(""), + }, + opts: nil, + wantBytes: nil, + wantErr: true, + }, + { + name: "invalid PEM", + pems: [][]byte{ + []byte("test"), + }, + opts: nil, + wantBytes: nil, + wantErr: true, + }, + { + name: "invalid cert", + pems: [][]byte{ + []byte(`-----BEGIN CERTIFICATE----- +FOO +-----END CERTIFICATE----- +`), + }, + opts: nil, + wantBytes: nil, + wantErr: true, + }, + { + name: "mixed valid and invalid PEMs", + pems: [][]byte{ + // Subject: CN=mariadb-client-ca + // Serial Number: a8:a4:f4:36:0a:dd:95:97:65:8a:b0:60:4d:f5:d1:47:ae:a9:6a + []byte(`-----BEGIN CERTIFICATE----- +MIICFDCCAX2gAwIBAgIUAKik9DYK3ZWXZYqwYE310UeuqWowDQYJKoZIhvcNAQEL +BQAwHDEaMBgGA1UEAwwRbWFyaWFkYi1jbGllbnQtY2EwHhcNMjQxMTA4MTcxMTM0 +WhcNMjUxMTA4MTcxMTM0WjAcMRowGAYDVQQDDBFtYXJpYWRiLWNsaWVudC1jYTCB +nzANBgkqhkiG9w0BAQEFAAOBjQAwgYkCgYEAvMhNoeq4M/PXLbvkeeuegP3zWouG +u7a35kvXS0YPMhlQV08GcyDyKkt6cG4GrZ3bJUhtcqmzT8oqYKxb9T6W9HU5+gpr +BCScUWViCYX0pKhucEPHP/5xAJuGnnzg0BqR2Tzt95IDmg+tkFKGOnVn9Qx9RfXO +ZpEHL42pNSEU/9kCAwEAAaNTMFEwHQYDVR0OBBYEFCUdplOwmy91F9mlBbQ58UuN +ob4fMB8GA1UdIwQYMBaAFCUdplOwmy91F9mlBbQ58UuNob4fMA8GA1UdEwEB/wQF +MAMBAf8wDQYJKoZIhvcNAQELBQADgYEASsuxA5A5aVjl1QN/SrLGLIMOvcDnYdtW +HpZmElox1PR72AFV2H/Ig/9ixK+3DykMbDf6RiwMZBtgQVuHTRD8QoEk/gG5OEOP +VDiVGD+f28/5eme54pwI9FUuKxujP0pj4VPiCKR2igJcJnCIAeDTlNmcs7CiXtIn +WVQiuKIOhYk= +-----END CERTIFICATE----- +`), + []byte("invalid data"), + }, + opts: nil, + // Subject: CN=mariadb-client-ca + // Serial Number: a8:a4:f4:36:0a:dd:95:97:65:8a:b0:60:4d:f5:d1:47:ae:a9:6a + wantBytes: []byte(`-----BEGIN CERTIFICATE----- +MIICFDCCAX2gAwIBAgIUAKik9DYK3ZWXZYqwYE310UeuqWowDQYJKoZIhvcNAQEL +BQAwHDEaMBgGA1UEAwwRbWFyaWFkYi1jbGllbnQtY2EwHhcNMjQxMTA4MTcxMTM0 +WhcNMjUxMTA4MTcxMTM0WjAcMRowGAYDVQQDDBFtYXJpYWRiLWNsaWVudC1jYTCB +nzANBgkqhkiG9w0BAQEFAAOBjQAwgYkCgYEAvMhNoeq4M/PXLbvkeeuegP3zWouG +u7a35kvXS0YPMhlQV08GcyDyKkt6cG4GrZ3bJUhtcqmzT8oqYKxb9T6W9HU5+gpr +BCScUWViCYX0pKhucEPHP/5xAJuGnnzg0BqR2Tzt95IDmg+tkFKGOnVn9Qx9RfXO +ZpEHL42pNSEU/9kCAwEAAaNTMFEwHQYDVR0OBBYEFCUdplOwmy91F9mlBbQ58UuN +ob4fMB8GA1UdIwQYMBaAFCUdplOwmy91F9mlBbQ58UuNob4fMA8GA1UdEwEB/wQF +MAMBAf8wDQYJKoZIhvcNAQELBQADgYEASsuxA5A5aVjl1QN/SrLGLIMOvcDnYdtW +HpZmElox1PR72AFV2H/Ig/9ixK+3DykMbDf6RiwMZBtgQVuHTRD8QoEk/gG5OEOP +VDiVGD+f28/5eme54pwI9FUuKxujP0pj4VPiCKR2igJcJnCIAeDTlNmcs7CiXtIn +WVQiuKIOhYk= +-----END CERTIFICATE----- +`), + wantErr: false, + }, + { + name: "single PEM with single block", + pems: [][]byte{ + // Subject: CN=mariadb-client-ca + // Serial Number: a8:a4:f4:36:0a:dd:95:97:65:8a:b0:60:4d:f5:d1:47:ae:a9:6a + []byte(`-----BEGIN CERTIFICATE----- +MIICFDCCAX2gAwIBAgIUAKik9DYK3ZWXZYqwYE310UeuqWowDQYJKoZIhvcNAQEL +BQAwHDEaMBgGA1UEAwwRbWFyaWFkYi1jbGllbnQtY2EwHhcNMjQxMTA4MTcxMTM0 +WhcNMjUxMTA4MTcxMTM0WjAcMRowGAYDVQQDDBFtYXJpYWRiLWNsaWVudC1jYTCB +nzANBgkqhkiG9w0BAQEFAAOBjQAwgYkCgYEAvMhNoeq4M/PXLbvkeeuegP3zWouG +u7a35kvXS0YPMhlQV08GcyDyKkt6cG4GrZ3bJUhtcqmzT8oqYKxb9T6W9HU5+gpr +BCScUWViCYX0pKhucEPHP/5xAJuGnnzg0BqR2Tzt95IDmg+tkFKGOnVn9Qx9RfXO +ZpEHL42pNSEU/9kCAwEAAaNTMFEwHQYDVR0OBBYEFCUdplOwmy91F9mlBbQ58UuN +ob4fMB8GA1UdIwQYMBaAFCUdplOwmy91F9mlBbQ58UuNob4fMA8GA1UdEwEB/wQF +MAMBAf8wDQYJKoZIhvcNAQELBQADgYEASsuxA5A5aVjl1QN/SrLGLIMOvcDnYdtW +HpZmElox1PR72AFV2H/Ig/9ixK+3DykMbDf6RiwMZBtgQVuHTRD8QoEk/gG5OEOP +VDiVGD+f28/5eme54pwI9FUuKxujP0pj4VPiCKR2igJcJnCIAeDTlNmcs7CiXtIn +WVQiuKIOhYk= +-----END CERTIFICATE----- +`), + }, + opts: nil, + // Subject: CN=mariadb-client-ca + // Serial Number: a8:a4:f4:36:0a:dd:95:97:65:8a:b0:60:4d:f5:d1:47:ae:a9:6a + wantBytes: []byte(`-----BEGIN CERTIFICATE----- +MIICFDCCAX2gAwIBAgIUAKik9DYK3ZWXZYqwYE310UeuqWowDQYJKoZIhvcNAQEL +BQAwHDEaMBgGA1UEAwwRbWFyaWFkYi1jbGllbnQtY2EwHhcNMjQxMTA4MTcxMTM0 +WhcNMjUxMTA4MTcxMTM0WjAcMRowGAYDVQQDDBFtYXJpYWRiLWNsaWVudC1jYTCB +nzANBgkqhkiG9w0BAQEFAAOBjQAwgYkCgYEAvMhNoeq4M/PXLbvkeeuegP3zWouG +u7a35kvXS0YPMhlQV08GcyDyKkt6cG4GrZ3bJUhtcqmzT8oqYKxb9T6W9HU5+gpr +BCScUWViCYX0pKhucEPHP/5xAJuGnnzg0BqR2Tzt95IDmg+tkFKGOnVn9Qx9RfXO +ZpEHL42pNSEU/9kCAwEAAaNTMFEwHQYDVR0OBBYEFCUdplOwmy91F9mlBbQ58UuN +ob4fMB8GA1UdIwQYMBaAFCUdplOwmy91F9mlBbQ58UuNob4fMA8GA1UdEwEB/wQF +MAMBAf8wDQYJKoZIhvcNAQELBQADgYEASsuxA5A5aVjl1QN/SrLGLIMOvcDnYdtW +HpZmElox1PR72AFV2H/Ig/9ixK+3DykMbDf6RiwMZBtgQVuHTRD8QoEk/gG5OEOP +VDiVGD+f28/5eme54pwI9FUuKxujP0pj4VPiCKR2igJcJnCIAeDTlNmcs7CiXtIn +WVQiuKIOhYk= +-----END CERTIFICATE----- +`), + wantErr: false, + }, + { + name: "multiple PEMs with single block", + pems: [][]byte{ + // Subject: CN=mariadb-client-ca + // Serial Number: a8:a4:f4:36:0a:dd:95:97:65:8a:b0:60:4d:f5:d1:47:ae:a9:6a + []byte(`-----BEGIN CERTIFICATE----- +MIICFDCCAX2gAwIBAgIUAKik9DYK3ZWXZYqwYE310UeuqWowDQYJKoZIhvcNAQEL +BQAwHDEaMBgGA1UEAwwRbWFyaWFkYi1jbGllbnQtY2EwHhcNMjQxMTA4MTcxMTM0 +WhcNMjUxMTA4MTcxMTM0WjAcMRowGAYDVQQDDBFtYXJpYWRiLWNsaWVudC1jYTCB +nzANBgkqhkiG9w0BAQEFAAOBjQAwgYkCgYEAvMhNoeq4M/PXLbvkeeuegP3zWouG +u7a35kvXS0YPMhlQV08GcyDyKkt6cG4GrZ3bJUhtcqmzT8oqYKxb9T6W9HU5+gpr +BCScUWViCYX0pKhucEPHP/5xAJuGnnzg0BqR2Tzt95IDmg+tkFKGOnVn9Qx9RfXO +ZpEHL42pNSEU/9kCAwEAAaNTMFEwHQYDVR0OBBYEFCUdplOwmy91F9mlBbQ58UuN +ob4fMB8GA1UdIwQYMBaAFCUdplOwmy91F9mlBbQ58UuNob4fMA8GA1UdEwEB/wQF +MAMBAf8wDQYJKoZIhvcNAQELBQADgYEASsuxA5A5aVjl1QN/SrLGLIMOvcDnYdtW +HpZmElox1PR72AFV2H/Ig/9ixK+3DykMbDf6RiwMZBtgQVuHTRD8QoEk/gG5OEOP +VDiVGD+f28/5eme54pwI9FUuKxujP0pj4VPiCKR2igJcJnCIAeDTlNmcs7CiXtIn +WVQiuKIOhYk= +-----END CERTIFICATE----- +`), + // Subject: CN=mariadb-server-ca + // Serial Number: 0a:c3:3a:30:47:9e:b3:0e:2a:4d:8a:e9:e6:56:95:ad:98:70:a2:91 + []byte(`-----BEGIN CERTIFICATE----- +MIICFDCCAX2gAwIBAgIUCsM6MEeesw4qTYrp5laVrZhwopEwDQYJKoZIhvcNAQEL +BQAwHDEaMBgGA1UEAwwRbWFyaWFkYi1zZXJ2ZXItY2EwHhcNMjQxMTA4MTcxMTM0 +WhcNMjUxMTA4MTcxMTM0WjAcMRowGAYDVQQDDBFtYXJpYWRiLXNlcnZlci1jYTCB +nzANBgkqhkiG9w0BAQEFAAOBjQAwgYkCgYEAv8H2G9AKtM+tc0rR4GAm6CHYTffF +wLICdiUpcnLkqvMIU/YFsjBDFCbzUkmz7Fni176s1LH3tekBneRkFZ7hoyEwccbX +e3gBnnfGma7DzWvmRWMYf0dpnk4stOxZ44V/DJ2pSE7zI7zrH6w9dLRmJFcaQrQO +WWXkPnsQL3LArEECAwEAAaNTMFEwHQYDVR0OBBYEFN8WJNuBah6vZkrTjBESN+fc +yvLOMB8GA1UdIwQYMBaAFN8WJNuBah6vZkrTjBESN+fcyvLOMA8GA1UdEwEB/wQF +MAMBAf8wDQYJKoZIhvcNAQELBQADgYEAqymYNbFm/DX20eAkTBYyih6oAz5ETNJU +jDqaasPK77oFD2eEjSCI3jewj8xYaGfTgohB+YdkM9VWN+s5zsxBakTY19U7GeQJ +xj8tutwZ3pBj0lLiTnzYb6VnXpl12TiHImapwwAkZEpMZ3W3o0TjK2gyc6F9o2h/ +idE60fGmuV8= +-----END CERTIFICATE----- +`), + }, + opts: nil, + // Subject: CN=mariadb-client-ca + // Serial Number: a8:a4:f4:36:0a:dd:95:97:65:8a:b0:60:4d:f5:d1:47:ae:a9:6a + // Subject: CN=mariadb-server-ca + // Serial Number: 0a:c3:3a:30:47:9e:b3:0e:2a:4d:8a:e9:e6:56:95:ad:98:70:a2:91 + wantBytes: []byte(`-----BEGIN CERTIFICATE----- +MIICFDCCAX2gAwIBAgIUAKik9DYK3ZWXZYqwYE310UeuqWowDQYJKoZIhvcNAQEL +BQAwHDEaMBgGA1UEAwwRbWFyaWFkYi1jbGllbnQtY2EwHhcNMjQxMTA4MTcxMTM0 +WhcNMjUxMTA4MTcxMTM0WjAcMRowGAYDVQQDDBFtYXJpYWRiLWNsaWVudC1jYTCB +nzANBgkqhkiG9w0BAQEFAAOBjQAwgYkCgYEAvMhNoeq4M/PXLbvkeeuegP3zWouG +u7a35kvXS0YPMhlQV08GcyDyKkt6cG4GrZ3bJUhtcqmzT8oqYKxb9T6W9HU5+gpr +BCScUWViCYX0pKhucEPHP/5xAJuGnnzg0BqR2Tzt95IDmg+tkFKGOnVn9Qx9RfXO +ZpEHL42pNSEU/9kCAwEAAaNTMFEwHQYDVR0OBBYEFCUdplOwmy91F9mlBbQ58UuN +ob4fMB8GA1UdIwQYMBaAFCUdplOwmy91F9mlBbQ58UuNob4fMA8GA1UdEwEB/wQF +MAMBAf8wDQYJKoZIhvcNAQELBQADgYEASsuxA5A5aVjl1QN/SrLGLIMOvcDnYdtW +HpZmElox1PR72AFV2H/Ig/9ixK+3DykMbDf6RiwMZBtgQVuHTRD8QoEk/gG5OEOP +VDiVGD+f28/5eme54pwI9FUuKxujP0pj4VPiCKR2igJcJnCIAeDTlNmcs7CiXtIn +WVQiuKIOhYk= +-----END CERTIFICATE----- +-----BEGIN CERTIFICATE----- +MIICFDCCAX2gAwIBAgIUCsM6MEeesw4qTYrp5laVrZhwopEwDQYJKoZIhvcNAQEL +BQAwHDEaMBgGA1UEAwwRbWFyaWFkYi1zZXJ2ZXItY2EwHhcNMjQxMTA4MTcxMTM0 +WhcNMjUxMTA4MTcxMTM0WjAcMRowGAYDVQQDDBFtYXJpYWRiLXNlcnZlci1jYTCB +nzANBgkqhkiG9w0BAQEFAAOBjQAwgYkCgYEAv8H2G9AKtM+tc0rR4GAm6CHYTffF +wLICdiUpcnLkqvMIU/YFsjBDFCbzUkmz7Fni176s1LH3tekBneRkFZ7hoyEwccbX +e3gBnnfGma7DzWvmRWMYf0dpnk4stOxZ44V/DJ2pSE7zI7zrH6w9dLRmJFcaQrQO +WWXkPnsQL3LArEECAwEAAaNTMFEwHQYDVR0OBBYEFN8WJNuBah6vZkrTjBESN+fc +yvLOMB8GA1UdIwQYMBaAFN8WJNuBah6vZkrTjBESN+fcyvLOMA8GA1UdEwEB/wQF +MAMBAf8wDQYJKoZIhvcNAQELBQADgYEAqymYNbFm/DX20eAkTBYyih6oAz5ETNJU +jDqaasPK77oFD2eEjSCI3jewj8xYaGfTgohB+YdkM9VWN+s5zsxBakTY19U7GeQJ +xj8tutwZ3pBj0lLiTnzYb6VnXpl12TiHImapwwAkZEpMZ3W3o0TjK2gyc6F9o2h/ +idE60fGmuV8= +-----END CERTIFICATE----- +`), + wantErr: false, + }, + { + name: "multiple PEMs with multiple blocks", + pems: [][]byte{ + // Subject: CN=mariadb-client-ca + // Serial Number: a8:a4:f4:36:0a:dd:95:97:65:8a:b0:60:4d:f5:d1:47:ae:a9:6a + // Subject: CN=mariadb-client-ca + // Serial Number: 27:ec:77:79:f5:d0:4c:c4:cb:b4:a1:8c:6d:18:5a:27:b2:86:82:26 + []byte(`-----BEGIN CERTIFICATE----- +MIICFDCCAX2gAwIBAgIUAKik9DYK3ZWXZYqwYE310UeuqWowDQYJKoZIhvcNAQEL +BQAwHDEaMBgGA1UEAwwRbWFyaWFkYi1jbGllbnQtY2EwHhcNMjQxMTA4MTcxMTM0 +WhcNMjUxMTA4MTcxMTM0WjAcMRowGAYDVQQDDBFtYXJpYWRiLWNsaWVudC1jYTCB +nzANBgkqhkiG9w0BAQEFAAOBjQAwgYkCgYEAvMhNoeq4M/PXLbvkeeuegP3zWouG +u7a35kvXS0YPMhlQV08GcyDyKkt6cG4GrZ3bJUhtcqmzT8oqYKxb9T6W9HU5+gpr +BCScUWViCYX0pKhucEPHP/5xAJuGnnzg0BqR2Tzt95IDmg+tkFKGOnVn9Qx9RfXO +ZpEHL42pNSEU/9kCAwEAAaNTMFEwHQYDVR0OBBYEFCUdplOwmy91F9mlBbQ58UuN +ob4fMB8GA1UdIwQYMBaAFCUdplOwmy91F9mlBbQ58UuNob4fMA8GA1UdEwEB/wQF +MAMBAf8wDQYJKoZIhvcNAQELBQADgYEASsuxA5A5aVjl1QN/SrLGLIMOvcDnYdtW +HpZmElox1PR72AFV2H/Ig/9ixK+3DykMbDf6RiwMZBtgQVuHTRD8QoEk/gG5OEOP +VDiVGD+f28/5eme54pwI9FUuKxujP0pj4VPiCKR2igJcJnCIAeDTlNmcs7CiXtIn +WVQiuKIOhYk= +-----END CERTIFICATE----- +-----BEGIN CERTIFICATE----- +MIICFDCCAX2gAwIBAgIUJ+x3efXQTMTLtKGMbRhaJ7KGgiYwDQYJKoZIhvcNAQEL +BQAwHDEaMBgGA1UEAwwRbWFyaWFkYi1jbGllbnQtY2EwHhcNMjQxMTA4MTcyMTI0 +WhcNMjUxMTA4MTcyMTI0WjAcMRowGAYDVQQDDBFtYXJpYWRiLWNsaWVudC1jYTCB +nzANBgkqhkiG9w0BAQEFAAOBjQAwgYkCgYEAoC2pswJefKNRXCbGMkIsY5C7c3D3 +3XVbrjIpOlO9Kml5z2gjcnf0CtsIAKYPBUVtrpR6j47ZJwPd5zjMxP1UEAqNkm/y +nHIhG6H72oHchyfKyPR3/rYfaQY7uOxKFTnj8y6+nq3k7E7siFZxMfF3WA6JO7NF +fGCS36p7T/Dk1k0CAwEAAaNTMFEwHQYDVR0OBBYEFL4jXMF9EbwDv1O+mxlHPm1Y +dM80MB8GA1UdIwQYMBaAFL4jXMF9EbwDv1O+mxlHPm1YdM80MA8GA1UdEwEB/wQF +MAMBAf8wDQYJKoZIhvcNAQELBQADgYEAapcNBQ1jiZe9OWS15G5+H6SpQjraqZSt +HeFxKIjQlYpGaasAI5ychVuWl/gn3m2nVB4F8O5hNF/Xj+J7mVqB1IUqkCVmZBq9 +a8gs4xAoVQq/ReNWfsq6BEdFbojkcaEq3OJS3KOX//qGnsLzzs288RRYfJUr5E2U +AByHbNLWiMg= +-----END CERTIFICATE----- +`), + // Subject: CN=mariadb-server-ca + // Serial Number: 0a:c3:3a:30:47:9e:b3:0e:2a:4d:8a:e9:e6:56:95:ad:98:70:a2:91 + // Subject: CN=mariadb-server-ca + // Serial Number: 7a:91:83:43:b9:cd:ce:0e:96:e2:d0:dc:fe:a3:ac:78:10:81:09:5d + []byte(`-----BEGIN CERTIFICATE----- +MIICFDCCAX2gAwIBAgIUCsM6MEeesw4qTYrp5laVrZhwopEwDQYJKoZIhvcNAQEL +BQAwHDEaMBgGA1UEAwwRbWFyaWFkYi1zZXJ2ZXItY2EwHhcNMjQxMTA4MTcxMTM0 +WhcNMjUxMTA4MTcxMTM0WjAcMRowGAYDVQQDDBFtYXJpYWRiLXNlcnZlci1jYTCB +nzANBgkqhkiG9w0BAQEFAAOBjQAwgYkCgYEAv8H2G9AKtM+tc0rR4GAm6CHYTffF +wLICdiUpcnLkqvMIU/YFsjBDFCbzUkmz7Fni176s1LH3tekBneRkFZ7hoyEwccbX +e3gBnnfGma7DzWvmRWMYf0dpnk4stOxZ44V/DJ2pSE7zI7zrH6w9dLRmJFcaQrQO +WWXkPnsQL3LArEECAwEAAaNTMFEwHQYDVR0OBBYEFN8WJNuBah6vZkrTjBESN+fc +yvLOMB8GA1UdIwQYMBaAFN8WJNuBah6vZkrTjBESN+fcyvLOMA8GA1UdEwEB/wQF +MAMBAf8wDQYJKoZIhvcNAQELBQADgYEAqymYNbFm/DX20eAkTBYyih6oAz5ETNJU +jDqaasPK77oFD2eEjSCI3jewj8xYaGfTgohB+YdkM9VWN+s5zsxBakTY19U7GeQJ +xj8tutwZ3pBj0lLiTnzYb6VnXpl12TiHImapwwAkZEpMZ3W3o0TjK2gyc6F9o2h/ +idE60fGmuV8= +-----END CERTIFICATE----- +-----BEGIN CERTIFICATE----- +MIICFDCCAX2gAwIBAgIUepGDQ7nNzg6W4tDc/qOseBCBCV0wDQYJKoZIhvcNAQEL +BQAwHDEaMBgGA1UEAwwRbWFyaWFkYi1zZXJ2ZXItY2EwHhcNMjQxMTA4MTcyMTI0 +WhcNMjUxMTA4MTcyMTI0WjAcMRowGAYDVQQDDBFtYXJpYWRiLXNlcnZlci1jYTCB +nzANBgkqhkiG9w0BAQEFAAOBjQAwgYkCgYEAnbr8mPxKvR0qWVJwFvJaE2qIvqOZ +Ug+NSweRXhb0Yu3XH0+V3wP8bMBo2jbx3xuX1WKkKwns+MxMiRp+ZgWZ3zSPxz0C +dmNVMeYtmWnDiGVBywU/zEvnqePTIg/JbV6O5XSPe6pCpXWsJ1RhJYhcq29fiUl8 +w5CF9/3ZJuxysk8CAwEAAaNTMFEwHQYDVR0OBBYEFEz5S5Ga0JVjkoDxfkjRTwgb +8Ki8MB8GA1UdIwQYMBaAFEz5S5Ga0JVjkoDxfkjRTwgb8Ki8MA8GA1UdEwEB/wQF +MAMBAf8wDQYJKoZIhvcNAQELBQADgYEASc/IzKvTTInV4WE/kPOcmVyFrJ0qf5gn +e4ci2mo5o3/Y7UalxmUnWdSwLGHnYjcwk0U1WRVqtAl73RYlu3Uw2Ehx7yHOyhDE +k1rkhnzkhp/qyt5VrpEtGjQmwDnwvbC/FYvbIWJ02asmjeqrg8IpSY+yJycbuzTW +IOz3lKAeaG0= +-----END CERTIFICATE----- +`), + }, + opts: nil, + // Subject: CN=mariadb-client-ca + // Serial Number: a8:a4:f4:36:0a:dd:95:97:65:8a:b0:60:4d:f5:d1:47:ae:a9:6a + // Subject: CN=mariadb-client-ca + // Serial Number: 11:f4:6a:f2:30:36:d6:ec:77:ce:3a:82:82:1c:25:96:db:06:f8:1c + // Subject: CN=mariadb-server-ca + // Serial Number: 0a:c3:3a:30:47:9e:b3:0e:2a:4d:8a:e9:e6:56:95:ad:98:70:a2:91 + // Subject: CN=mariadb-server-ca + // Serial Number: 0d:05:59:c6:ea:0f:f8:d8:c7:ed:c8:59:2b:4e:be:ee:d9:b9:19:44 + wantBytes: []byte(`-----BEGIN CERTIFICATE----- +MIICFDCCAX2gAwIBAgIUAKik9DYK3ZWXZYqwYE310UeuqWowDQYJKoZIhvcNAQEL +BQAwHDEaMBgGA1UEAwwRbWFyaWFkYi1jbGllbnQtY2EwHhcNMjQxMTA4MTcxMTM0 +WhcNMjUxMTA4MTcxMTM0WjAcMRowGAYDVQQDDBFtYXJpYWRiLWNsaWVudC1jYTCB +nzANBgkqhkiG9w0BAQEFAAOBjQAwgYkCgYEAvMhNoeq4M/PXLbvkeeuegP3zWouG +u7a35kvXS0YPMhlQV08GcyDyKkt6cG4GrZ3bJUhtcqmzT8oqYKxb9T6W9HU5+gpr +BCScUWViCYX0pKhucEPHP/5xAJuGnnzg0BqR2Tzt95IDmg+tkFKGOnVn9Qx9RfXO +ZpEHL42pNSEU/9kCAwEAAaNTMFEwHQYDVR0OBBYEFCUdplOwmy91F9mlBbQ58UuN +ob4fMB8GA1UdIwQYMBaAFCUdplOwmy91F9mlBbQ58UuNob4fMA8GA1UdEwEB/wQF +MAMBAf8wDQYJKoZIhvcNAQELBQADgYEASsuxA5A5aVjl1QN/SrLGLIMOvcDnYdtW +HpZmElox1PR72AFV2H/Ig/9ixK+3DykMbDf6RiwMZBtgQVuHTRD8QoEk/gG5OEOP +VDiVGD+f28/5eme54pwI9FUuKxujP0pj4VPiCKR2igJcJnCIAeDTlNmcs7CiXtIn +WVQiuKIOhYk= +-----END CERTIFICATE----- +-----BEGIN CERTIFICATE----- +MIICFDCCAX2gAwIBAgIUJ+x3efXQTMTLtKGMbRhaJ7KGgiYwDQYJKoZIhvcNAQEL +BQAwHDEaMBgGA1UEAwwRbWFyaWFkYi1jbGllbnQtY2EwHhcNMjQxMTA4MTcyMTI0 +WhcNMjUxMTA4MTcyMTI0WjAcMRowGAYDVQQDDBFtYXJpYWRiLWNsaWVudC1jYTCB +nzANBgkqhkiG9w0BAQEFAAOBjQAwgYkCgYEAoC2pswJefKNRXCbGMkIsY5C7c3D3 +3XVbrjIpOlO9Kml5z2gjcnf0CtsIAKYPBUVtrpR6j47ZJwPd5zjMxP1UEAqNkm/y +nHIhG6H72oHchyfKyPR3/rYfaQY7uOxKFTnj8y6+nq3k7E7siFZxMfF3WA6JO7NF +fGCS36p7T/Dk1k0CAwEAAaNTMFEwHQYDVR0OBBYEFL4jXMF9EbwDv1O+mxlHPm1Y +dM80MB8GA1UdIwQYMBaAFL4jXMF9EbwDv1O+mxlHPm1YdM80MA8GA1UdEwEB/wQF +MAMBAf8wDQYJKoZIhvcNAQELBQADgYEAapcNBQ1jiZe9OWS15G5+H6SpQjraqZSt +HeFxKIjQlYpGaasAI5ychVuWl/gn3m2nVB4F8O5hNF/Xj+J7mVqB1IUqkCVmZBq9 +a8gs4xAoVQq/ReNWfsq6BEdFbojkcaEq3OJS3KOX//qGnsLzzs288RRYfJUr5E2U +AByHbNLWiMg= +-----END CERTIFICATE----- +-----BEGIN CERTIFICATE----- +MIICFDCCAX2gAwIBAgIUCsM6MEeesw4qTYrp5laVrZhwopEwDQYJKoZIhvcNAQEL +BQAwHDEaMBgGA1UEAwwRbWFyaWFkYi1zZXJ2ZXItY2EwHhcNMjQxMTA4MTcxMTM0 +WhcNMjUxMTA4MTcxMTM0WjAcMRowGAYDVQQDDBFtYXJpYWRiLXNlcnZlci1jYTCB +nzANBgkqhkiG9w0BAQEFAAOBjQAwgYkCgYEAv8H2G9AKtM+tc0rR4GAm6CHYTffF +wLICdiUpcnLkqvMIU/YFsjBDFCbzUkmz7Fni176s1LH3tekBneRkFZ7hoyEwccbX +e3gBnnfGma7DzWvmRWMYf0dpnk4stOxZ44V/DJ2pSE7zI7zrH6w9dLRmJFcaQrQO +WWXkPnsQL3LArEECAwEAAaNTMFEwHQYDVR0OBBYEFN8WJNuBah6vZkrTjBESN+fc +yvLOMB8GA1UdIwQYMBaAFN8WJNuBah6vZkrTjBESN+fcyvLOMA8GA1UdEwEB/wQF +MAMBAf8wDQYJKoZIhvcNAQELBQADgYEAqymYNbFm/DX20eAkTBYyih6oAz5ETNJU +jDqaasPK77oFD2eEjSCI3jewj8xYaGfTgohB+YdkM9VWN+s5zsxBakTY19U7GeQJ +xj8tutwZ3pBj0lLiTnzYb6VnXpl12TiHImapwwAkZEpMZ3W3o0TjK2gyc6F9o2h/ +idE60fGmuV8= +-----END CERTIFICATE----- +-----BEGIN CERTIFICATE----- +MIICFDCCAX2gAwIBAgIUepGDQ7nNzg6W4tDc/qOseBCBCV0wDQYJKoZIhvcNAQEL +BQAwHDEaMBgGA1UEAwwRbWFyaWFkYi1zZXJ2ZXItY2EwHhcNMjQxMTA4MTcyMTI0 +WhcNMjUxMTA4MTcyMTI0WjAcMRowGAYDVQQDDBFtYXJpYWRiLXNlcnZlci1jYTCB +nzANBgkqhkiG9w0BAQEFAAOBjQAwgYkCgYEAnbr8mPxKvR0qWVJwFvJaE2qIvqOZ +Ug+NSweRXhb0Yu3XH0+V3wP8bMBo2jbx3xuX1WKkKwns+MxMiRp+ZgWZ3zSPxz0C +dmNVMeYtmWnDiGVBywU/zEvnqePTIg/JbV6O5XSPe6pCpXWsJ1RhJYhcq29fiUl8 +w5CF9/3ZJuxysk8CAwEAAaNTMFEwHQYDVR0OBBYEFEz5S5Ga0JVjkoDxfkjRTwgb +8Ki8MB8GA1UdIwQYMBaAFEz5S5Ga0JVjkoDxfkjRTwgb8Ki8MA8GA1UdEwEB/wQF +MAMBAf8wDQYJKoZIhvcNAQELBQADgYEASc/IzKvTTInV4WE/kPOcmVyFrJ0qf5gn +e4ci2mo5o3/Y7UalxmUnWdSwLGHnYjcwk0U1WRVqtAl73RYlu3Uw2Ehx7yHOyhDE +k1rkhnzkhp/qyt5VrpEtGjQmwDnwvbC/FYvbIWJ02asmjeqrg8IpSY+yJycbuzTW +IOz3lKAeaG0= +-----END CERTIFICATE----- +`), + wantErr: false, + }, + { + name: "duplicated certs", + pems: [][]byte{ + // Subject: CN=mariadb-client-ca + // Serial Number: a8:a4:f4:36:0a:dd:95:97:65:8a:b0:60:4d:f5:d1:47:ae:a9:6a + // Subject: CN=mariadb-client-ca + // Serial Number: a8:a4:f4:36:0a:dd:95:97:65:8a:b0:60:4d:f5:d1:47:ae:a9:6a + []byte(`-----BEGIN CERTIFICATE----- +MIICFDCCAX2gAwIBAgIUAKik9DYK3ZWXZYqwYE310UeuqWowDQYJKoZIhvcNAQEL +BQAwHDEaMBgGA1UEAwwRbWFyaWFkYi1jbGllbnQtY2EwHhcNMjQxMTA4MTcxMTM0 +WhcNMjUxMTA4MTcxMTM0WjAcMRowGAYDVQQDDBFtYXJpYWRiLWNsaWVudC1jYTCB +nzANBgkqhkiG9w0BAQEFAAOBjQAwgYkCgYEAvMhNoeq4M/PXLbvkeeuegP3zWouG +u7a35kvXS0YPMhlQV08GcyDyKkt6cG4GrZ3bJUhtcqmzT8oqYKxb9T6W9HU5+gpr +BCScUWViCYX0pKhucEPHP/5xAJuGnnzg0BqR2Tzt95IDmg+tkFKGOnVn9Qx9RfXO +ZpEHL42pNSEU/9kCAwEAAaNTMFEwHQYDVR0OBBYEFCUdplOwmy91F9mlBbQ58UuN +ob4fMB8GA1UdIwQYMBaAFCUdplOwmy91F9mlBbQ58UuNob4fMA8GA1UdEwEB/wQF +MAMBAf8wDQYJKoZIhvcNAQELBQADgYEASsuxA5A5aVjl1QN/SrLGLIMOvcDnYdtW +HpZmElox1PR72AFV2H/Ig/9ixK+3DykMbDf6RiwMZBtgQVuHTRD8QoEk/gG5OEOP +VDiVGD+f28/5eme54pwI9FUuKxujP0pj4VPiCKR2igJcJnCIAeDTlNmcs7CiXtIn +WVQiuKIOhYk= +-----END CERTIFICATE----- +-----BEGIN CERTIFICATE----- +MIICFDCCAX2gAwIBAgIUAKik9DYK3ZWXZYqwYE310UeuqWowDQYJKoZIhvcNAQEL +BQAwHDEaMBgGA1UEAwwRbWFyaWFkYi1jbGllbnQtY2EwHhcNMjQxMTA4MTcxMTM0 +WhcNMjUxMTA4MTcxMTM0WjAcMRowGAYDVQQDDBFtYXJpYWRiLWNsaWVudC1jYTCB +nzANBgkqhkiG9w0BAQEFAAOBjQAwgYkCgYEAvMhNoeq4M/PXLbvkeeuegP3zWouG +u7a35kvXS0YPMhlQV08GcyDyKkt6cG4GrZ3bJUhtcqmzT8oqYKxb9T6W9HU5+gpr +BCScUWViCYX0pKhucEPHP/5xAJuGnnzg0BqR2Tzt95IDmg+tkFKGOnVn9Qx9RfXO +ZpEHL42pNSEU/9kCAwEAAaNTMFEwHQYDVR0OBBYEFCUdplOwmy91F9mlBbQ58UuN +ob4fMB8GA1UdIwQYMBaAFCUdplOwmy91F9mlBbQ58UuNob4fMA8GA1UdEwEB/wQF +MAMBAf8wDQYJKoZIhvcNAQELBQADgYEASsuxA5A5aVjl1QN/SrLGLIMOvcDnYdtW +HpZmElox1PR72AFV2H/Ig/9ixK+3DykMbDf6RiwMZBtgQVuHTRD8QoEk/gG5OEOP +VDiVGD+f28/5eme54pwI9FUuKxujP0pj4VPiCKR2igJcJnCIAeDTlNmcs7CiXtIn +WVQiuKIOhYk= +-----END CERTIFICATE----- +`), + // Subject: CN=mariadb-server-ca + // Serial Number: 0a:c3:3a:30:47:9e:b3:0e:2a:4d:8a:e9:e6:56:95:ad:98:70:a2:91 + // Subject: CN=mariadb-server-ca + // Serial Number: 0a:c3:3a:30:47:9e:b3:0e:2a:4d:8a:e9:e6:56:95:ad:98:70:a2:91 + []byte(`-----BEGIN CERTIFICATE----- +MIICFDCCAX2gAwIBAgIUCsM6MEeesw4qTYrp5laVrZhwopEwDQYJKoZIhvcNAQEL +BQAwHDEaMBgGA1UEAwwRbWFyaWFkYi1zZXJ2ZXItY2EwHhcNMjQxMTA4MTcxMTM0 +WhcNMjUxMTA4MTcxMTM0WjAcMRowGAYDVQQDDBFtYXJpYWRiLXNlcnZlci1jYTCB +nzANBgkqhkiG9w0BAQEFAAOBjQAwgYkCgYEAv8H2G9AKtM+tc0rR4GAm6CHYTffF +wLICdiUpcnLkqvMIU/YFsjBDFCbzUkmz7Fni176s1LH3tekBneRkFZ7hoyEwccbX +e3gBnnfGma7DzWvmRWMYf0dpnk4stOxZ44V/DJ2pSE7zI7zrH6w9dLRmJFcaQrQO +WWXkPnsQL3LArEECAwEAAaNTMFEwHQYDVR0OBBYEFN8WJNuBah6vZkrTjBESN+fc +yvLOMB8GA1UdIwQYMBaAFN8WJNuBah6vZkrTjBESN+fcyvLOMA8GA1UdEwEB/wQF +MAMBAf8wDQYJKoZIhvcNAQELBQADgYEAqymYNbFm/DX20eAkTBYyih6oAz5ETNJU +jDqaasPK77oFD2eEjSCI3jewj8xYaGfTgohB+YdkM9VWN+s5zsxBakTY19U7GeQJ +xj8tutwZ3pBj0lLiTnzYb6VnXpl12TiHImapwwAkZEpMZ3W3o0TjK2gyc6F9o2h/ +idE60fGmuV8= +-----END CERTIFICATE----- +-----BEGIN CERTIFICATE----- +MIICFDCCAX2gAwIBAgIUCsM6MEeesw4qTYrp5laVrZhwopEwDQYJKoZIhvcNAQEL +BQAwHDEaMBgGA1UEAwwRbWFyaWFkYi1zZXJ2ZXItY2EwHhcNMjQxMTA4MTcxMTM0 +WhcNMjUxMTA4MTcxMTM0WjAcMRowGAYDVQQDDBFtYXJpYWRiLXNlcnZlci1jYTCB +nzANBgkqhkiG9w0BAQEFAAOBjQAwgYkCgYEAv8H2G9AKtM+tc0rR4GAm6CHYTffF +wLICdiUpcnLkqvMIU/YFsjBDFCbzUkmz7Fni176s1LH3tekBneRkFZ7hoyEwccbX +e3gBnnfGma7DzWvmRWMYf0dpnk4stOxZ44V/DJ2pSE7zI7zrH6w9dLRmJFcaQrQO +WWXkPnsQL3LArEECAwEAAaNTMFEwHQYDVR0OBBYEFN8WJNuBah6vZkrTjBESN+fc +yvLOMB8GA1UdIwQYMBaAFN8WJNuBah6vZkrTjBESN+fcyvLOMA8GA1UdEwEB/wQF +MAMBAf8wDQYJKoZIhvcNAQELBQADgYEAqymYNbFm/DX20eAkTBYyih6oAz5ETNJU +jDqaasPK77oFD2eEjSCI3jewj8xYaGfTgohB+YdkM9VWN+s5zsxBakTY19U7GeQJ +xj8tutwZ3pBj0lLiTnzYb6VnXpl12TiHImapwwAkZEpMZ3W3o0TjK2gyc6F9o2h/ +idE60fGmuV8= +-----END CERTIFICATE----- +`), + }, + opts: nil, + // Subject: CN=mariadb-client-ca + // Serial Number: a8:a4:f4:36:0a:dd:95:97:65:8a:b0:60:4d:f5:d1:47:ae:a9:6a + // Subject: CN=mariadb-server-ca + // Serial Number: 0a:c3:3a:30:47:9e:b3:0e:2a:4d:8a:e9:e6:56:95:ad:98:70:a2:91 + wantBytes: []byte(`-----BEGIN CERTIFICATE----- +MIICFDCCAX2gAwIBAgIUAKik9DYK3ZWXZYqwYE310UeuqWowDQYJKoZIhvcNAQEL +BQAwHDEaMBgGA1UEAwwRbWFyaWFkYi1jbGllbnQtY2EwHhcNMjQxMTA4MTcxMTM0 +WhcNMjUxMTA4MTcxMTM0WjAcMRowGAYDVQQDDBFtYXJpYWRiLWNsaWVudC1jYTCB +nzANBgkqhkiG9w0BAQEFAAOBjQAwgYkCgYEAvMhNoeq4M/PXLbvkeeuegP3zWouG +u7a35kvXS0YPMhlQV08GcyDyKkt6cG4GrZ3bJUhtcqmzT8oqYKxb9T6W9HU5+gpr +BCScUWViCYX0pKhucEPHP/5xAJuGnnzg0BqR2Tzt95IDmg+tkFKGOnVn9Qx9RfXO +ZpEHL42pNSEU/9kCAwEAAaNTMFEwHQYDVR0OBBYEFCUdplOwmy91F9mlBbQ58UuN +ob4fMB8GA1UdIwQYMBaAFCUdplOwmy91F9mlBbQ58UuNob4fMA8GA1UdEwEB/wQF +MAMBAf8wDQYJKoZIhvcNAQELBQADgYEASsuxA5A5aVjl1QN/SrLGLIMOvcDnYdtW +HpZmElox1PR72AFV2H/Ig/9ixK+3DykMbDf6RiwMZBtgQVuHTRD8QoEk/gG5OEOP +VDiVGD+f28/5eme54pwI9FUuKxujP0pj4VPiCKR2igJcJnCIAeDTlNmcs7CiXtIn +WVQiuKIOhYk= +-----END CERTIFICATE----- +-----BEGIN CERTIFICATE----- +MIICFDCCAX2gAwIBAgIUCsM6MEeesw4qTYrp5laVrZhwopEwDQYJKoZIhvcNAQEL +BQAwHDEaMBgGA1UEAwwRbWFyaWFkYi1zZXJ2ZXItY2EwHhcNMjQxMTA4MTcxMTM0 +WhcNMjUxMTA4MTcxMTM0WjAcMRowGAYDVQQDDBFtYXJpYWRiLXNlcnZlci1jYTCB +nzANBgkqhkiG9w0BAQEFAAOBjQAwgYkCgYEAv8H2G9AKtM+tc0rR4GAm6CHYTffF +wLICdiUpcnLkqvMIU/YFsjBDFCbzUkmz7Fni176s1LH3tekBneRkFZ7hoyEwccbX +e3gBnnfGma7DzWvmRWMYf0dpnk4stOxZ44V/DJ2pSE7zI7zrH6w9dLRmJFcaQrQO +WWXkPnsQL3LArEECAwEAAaNTMFEwHQYDVR0OBBYEFN8WJNuBah6vZkrTjBESN+fc +yvLOMB8GA1UdIwQYMBaAFN8WJNuBah6vZkrTjBESN+fcyvLOMA8GA1UdEwEB/wQF +MAMBAf8wDQYJKoZIhvcNAQELBQADgYEAqymYNbFm/DX20eAkTBYyih6oAz5ETNJU +jDqaasPK77oFD2eEjSCI3jewj8xYaGfTgohB+YdkM9VWN+s5zsxBakTY19U7GeQJ +xj8tutwZ3pBj0lLiTnzYb6VnXpl12TiHImapwwAkZEpMZ3W3o0TjK2gyc6F9o2h/ +idE60fGmuV8= +-----END CERTIFICATE----- +`), + wantErr: false, + }, + { + name: "expired cert", + pems: [][]byte{ + // Expired cert + []byte(`-----BEGIN CERTIFICATE----- +MIIFSzCCBDOgAwIBAgIQSueVSfqavj8QDxekeOFpCTANBgkqhkiG9w0BAQsFADCB +kDELMAkGA1UEBhMCR0IxGzAZBgNVBAgTEkdyZWF0ZXIgTWFuY2hlc3RlcjEQMA4G +A1UEBxMHU2FsZm9yZDEaMBgGA1UEChMRQ09NT0RPIENBIExpbWl0ZWQxNjA0BgNV +BAMTLUNPTU9ETyBSU0EgRG9tYWluIFZhbGlkYXRpb24gU2VjdXJlIFNlcnZlciBD +QTAeFw0xNTA0MDkwMDAwMDBaFw0xNTA0MTIyMzU5NTlaMFkxITAfBgNVBAsTGERv +bWFpbiBDb250cm9sIFZhbGlkYXRlZDEdMBsGA1UECxMUUG9zaXRpdmVTU0wgV2ls +ZGNhcmQxFTATBgNVBAMUDCouYmFkc3NsLmNvbTCCASIwDQYJKoZIhvcNAQEBBQAD +ggEPADCCAQoCggEBAMIE7PiM7gTCs9hQ1XBYzJMY61yoaEmwIrX5lZ6xKyx2PmzA +S2BMTOqytMAPgLaw+XLJhgL5XEFdEyt/ccRLvOmULlA3pmccYYz2QULFRtMWhyef +dOsKnRFSJiFzbIRMeVXk0WvoBj1IFVKtsyjbqv9u/2CVSndrOfEk0TG23U3AxPxT +uW1CrbV8/q71FdIzSOciccfCFHpsKOo3St/qbLVytH5aohbcabFXRNsKEqveww9H +dFxBIuGa+RuT5q0iBikusbpJHAwnnqP7i/dAcgCskgjZjFeEU4EFy+b+a1SYQCeF +xxC7c3DvaRhBB0VVfPlkPz0sw6l865MaTIbRyoUCAwEAAaOCAdUwggHRMB8GA1Ud +IwQYMBaAFJCvajqUWgvYkOoSVnPfQ7Q6KNrnMB0GA1UdDgQWBBSd7sF7gQs6R2lx +GH0RN5O8pRs/+zAOBgNVHQ8BAf8EBAMCBaAwDAYDVR0TAQH/BAIwADAdBgNVHSUE +FjAUBggrBgEFBQcDAQYIKwYBBQUHAwIwTwYDVR0gBEgwRjA6BgsrBgEEAbIxAQIC +BzArMCkGCCsGAQUFBwIBFh1odHRwczovL3NlY3VyZS5jb21vZG8uY29tL0NQUzAI +BgZngQwBAgEwVAYDVR0fBE0wSzBJoEegRYZDaHR0cDovL2NybC5jb21vZG9jYS5j +b20vQ09NT0RPUlNBRG9tYWluVmFsaWRhdGlvblNlY3VyZVNlcnZlckNBLmNybDCB +hQYIKwYBBQUHAQEEeTB3ME8GCCsGAQUFBzAChkNodHRwOi8vY3J0LmNvbW9kb2Nh +LmNvbS9DT01PRE9SU0FEb21haW5WYWxpZGF0aW9uU2VjdXJlU2VydmVyQ0EuY3J0 +MCQGCCsGAQUFBzABhhhodHRwOi8vb2NzcC5jb21vZG9jYS5jb20wIwYDVR0RBBww +GoIMKi5iYWRzc2wuY29tggpiYWRzc2wuY29tMA0GCSqGSIb3DQEBCwUAA4IBAQBq +evHa/wMHcnjFZqFPRkMOXxQhjHUa6zbgH6QQFezaMyV8O7UKxwE4PSf9WNnM6i1p +OXy+l+8L1gtY54x/v7NMHfO3kICmNnwUW+wHLQI+G1tjWxWrAPofOxkt3+IjEBEH +fnJ/4r+3ABuYLyw/zoWaJ4wQIghBK4o+gk783SHGVnRwpDTysUCeK1iiWQ8dSO/r +ET7BSp68ZVVtxqPv1dSWzfGuJ/ekVxQ8lEEFeouhN0fX9X3c+s5vMaKwjOrMEpsi +8TRwz311SotoKQwe6Zaoz7ASH1wq7mcvf71z81oBIgxw+s1F73hczg36TuHvzmWf +RwxPuzZEaFZcVlmtqoq8 +-----END CERTIFICATE----- +`), + }, + opts: []BundleOption{ + WithSkipExpired(true), + }, + wantBytes: nil, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + bundleBytes, err := BundleCertificatePEMs(tt.pems, tt.opts...) + + if tt.wantErr && err == nil { + t.Error("expect error to have occurred, got nil") + } + if !tt.wantErr && err != nil { + t.Errorf("expect error to not have occurred, got: %v", err) + } + if diff := cmp.Diff(tt.wantBytes, bundleBytes); diff != "" { + t.Errorf("unexpected bundle content (-want +got):\n%s", diff) + } + }) + } +} diff --git a/pkg/pki/pki.go b/pkg/pki/pki.go deleted file mode 100644 index ea725367d1..0000000000 --- a/pkg/pki/pki.go +++ /dev/null @@ -1,264 +0,0 @@ -package pki - -import ( - "bytes" - "crypto/rand" - "crypto/rsa" - "crypto/tls" - "crypto/x509" - "crypto/x509/pkix" - "encoding/pem" - "errors" - "fmt" - "math/big" - "time" - - corev1 "k8s.io/api/core/v1" -) - -var ( - tlsCert = "tls.crt" - tlsKey = "tls.key" - defaultCAValidityDuration = 4 * 365 * 24 * time.Hour - defaultCertValidityDuration = 365 * 24 * time.Hour -) - -type KeyPair struct { - Cert *x509.Certificate - Key *rsa.PrivateKey - CertPEM []byte - KeyPEM []byte -} - -func (k *KeyPair) IsValid() bool { - return k.Cert != nil && k.Key != nil && len(k.CertPEM) > 0 && len(k.KeyPEM) > 0 -} - -func (k *KeyPair) FillTLSSecret(secret *corev1.Secret) { - if secret.Data == nil { - secret.Data = make(map[string][]byte) - } - secret.Data[tlsCert] = k.CertPEM - secret.Data[tlsKey] = k.KeyPEM -} - -func KeyPairFromTLSSecret(secret *corev1.Secret) (*KeyPair, error) { - if secret.Data == nil { - return nil, errors.New("TLS Secret is empty") - } - certPEM := secret.Data[tlsCert] - keyPEM := secret.Data[tlsKey] - return KeyPairFromPEM(certPEM, keyPEM) -} - -func KeyPairFromPEM(certPEM, keyPEM []byte) (*KeyPair, error) { - if len(certPEM) == 0 || len(keyPEM) == 0 { - return nil, errors.New("TLS Secret is empty") - } - pemBlockCert, _ := pem.Decode(certPEM) - if pemBlockCert == nil { - return nil, errors.New("Bad certificate") - } - cert, err := x509.ParseCertificate(pemBlockCert.Bytes) - if err != nil { - return nil, fmt.Errorf("Error parsing x509 certificate: %v", err) - } - - pemBlockKey, _ := pem.Decode(keyPEM) - if pemBlockKey == nil { - return nil, fmt.Errorf("Bad private key") - } - key, err := x509.ParsePKCS1PrivateKey(pemBlockKey.Bytes) - if err != nil { - return nil, fmt.Errorf("Error parsing PKCS1 private key: %v", err) - } - - return &KeyPair{ - Cert: cert, - Key: key, - CertPEM: certPEM, - KeyPEM: keyPEM, - }, nil -} - -type X509Opts struct { - CommonName string - DNSNames []string - Organization string - NotBefore time.Time - NotAfter time.Time -} - -type X509Opt func(*X509Opts) - -func WithCommonName(name string) X509Opt { - return func(x *X509Opts) { - x.CommonName = name - } -} - -func WithDNSNames(dnsNames []string) X509Opt { - return func(x *X509Opts) { - x.DNSNames = dnsNames - } -} - -func WithOrganization(org string) X509Opt { - return func(x *X509Opts) { - x.Organization = org - } -} - -func WithNotBefore(notBefore time.Time) X509Opt { - return func(x *X509Opts) { - x.NotBefore = notBefore - } -} - -func WithNotAfter(notAfter time.Time) X509Opt { - return func(x *X509Opts) { - x.NotAfter = notAfter - } -} - -func CreateCA(x509Opts ...X509Opt) (*KeyPair, error) { - opts := X509Opts{ - CommonName: "mariadb-operator", - Organization: "mariadb-operator", - NotBefore: time.Now().Add(-1 * time.Hour), - NotAfter: time.Now().Add(defaultCAValidityDuration), - } - for _, setOpt := range x509Opts { - setOpt(&opts) - } - tpl := &x509.Certificate{ - SerialNumber: big.NewInt(0), - Subject: pkix.Name{ - CommonName: opts.CommonName, - Organization: []string{opts.Organization}, - }, - DNSNames: []string{ - opts.CommonName, - }, - NotBefore: opts.NotBefore, - NotAfter: opts.NotAfter, - KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment | x509.KeyUsageCertSign, - BasicConstraintsValid: true, - IsCA: true, - } - return createKeyPair(tpl, nil) -} - -func CreateCert(caKeyPair *KeyPair, x509Opts ...X509Opt) (*KeyPair, error) { - opts := X509Opts{ - NotBefore: time.Now().Add(-1 * time.Hour), - NotAfter: time.Now().Add(defaultCertValidityDuration), - } - for _, setOpt := range x509Opts { - setOpt(&opts) - } - if opts.CommonName == "" || opts.DNSNames == nil { - return nil, errors.New("CommonName and DNSNames are mandatory") - } - - tpl := &x509.Certificate{ - SerialNumber: big.NewInt(1), - Subject: pkix.Name{ - CommonName: opts.CommonName, - }, - DNSNames: opts.DNSNames, - NotBefore: opts.NotBefore, - NotAfter: opts.NotAfter, - KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment, - ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, - BasicConstraintsValid: true, - } - return createKeyPair(tpl, caKeyPair) -} - -func ParseCert(bytes []byte) (*x509.Certificate, error) { - pemBlockCert, _ := pem.Decode(bytes) - if pemBlockCert == nil { - return nil, errors.New("Error parsing PEM block") - } - parsedCert, err := x509.ParseCertificate(pemBlockCert.Bytes) - if err != nil { - return nil, err - } - return parsedCert, nil -} - -func ValidCert(caCert *x509.Certificate, certKeyPair *KeyPair, dnsName string, at time.Time) (bool, error) { - if !certKeyPair.IsValid() { - return false, errors.New("Invalid certificate KeyPair") - } - _, err := tls.X509KeyPair(certKeyPair.CertPEM, certKeyPair.KeyPEM) - if err != nil { - return false, err - } - parsedCert, err := ParseCert(certKeyPair.CertPEM) - if err != nil { - return false, err - } - - pool := x509.NewCertPool() - pool.AddCert(caCert) - _, err = parsedCert.Verify(x509.VerifyOptions{ - DNSName: dnsName, - Roots: pool, - CurrentTime: at, - }) - if err != nil { - return false, err - } - return true, nil -} - -func ValidCACert(keyPair *KeyPair, dnsName string, at time.Time) (bool, error) { - return ValidCert(keyPair.Cert, keyPair, dnsName, at) -} - -func createKeyPair(tpl *x509.Certificate, caKeyPair *KeyPair) (*KeyPair, error) { - key, err := rsa.GenerateKey(rand.Reader, 2048) - if err != nil { - return nil, err - } - parent := tpl - privateKey := key - if caKeyPair != nil { - parent = caKeyPair.Cert - privateKey = caKeyPair.Key - } - - certBytes, err := x509.CreateCertificate(rand.Reader, tpl, parent, key.Public(), privateKey) - if err != nil { - return nil, err - } - cert, err := x509.ParseCertificate(certBytes) - if err != nil { - return nil, err - } - certPEM, keyPEM, err := pemEncodeKeyPair(certBytes, key) - if err != nil { - return nil, err - } - - return &KeyPair{ - Cert: cert, - Key: key, - CertPEM: certPEM, - KeyPEM: keyPEM, - }, nil -} - -func pemEncodeKeyPair(certificateDER []byte, key *rsa.PrivateKey) (certPEM []byte, keyPEM []byte, err error) { - certBuf := &bytes.Buffer{} - if err := pem.Encode(certBuf, &pem.Block{Type: "CERTIFICATE", Bytes: certificateDER}); err != nil { - return nil, nil, err - } - keyBuf := &bytes.Buffer{} - if err := pem.Encode(keyBuf, &pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key)}); err != nil { - return nil, nil, err - } - return certBuf.Bytes(), keyBuf.Bytes(), nil -} diff --git a/pkg/pki/pki_test.go b/pkg/pki/pki_test.go deleted file mode 100644 index 265944563d..0000000000 --- a/pkg/pki/pki_test.go +++ /dev/null @@ -1,320 +0,0 @@ -package pki - -import ( - "testing" - "time" - - corev1 "k8s.io/api/core/v1" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" -) - -var ( - testTLSCert = ` ------BEGIN CERTIFICATE----- -MIID3DCCAsSgAwIBAgIBATANBgkqhkiG9w0BAQsFADA2MRkwFwYDVQQKExBtYXJp -YWRiLW9wZXJhdG9yMRkwFwYDVQQDExBtYXJpYWRiLW9wZXJhdG9yMB4XDTIzMTEw -NTEwMzAxNVoXDTIzMTEwNTEyMzAxNVowLzEtMCsGA1UEAxMkbWFyaWFkYi1vcGVy -YXRvci13ZWJob29rLmRlZmF1bHQuc3ZjMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8A -MIIBCgKCAQEA1l202NMTln0/ngg4JXUJLJXvhSjjHimO22c47tHhWvnzhtnKCrH8 -cWBnnxO11os5PcNIUYTxn04mZPRs+p1YkE9DMlp9Lgy/38304rr4kjllVspvl9Md -relqbcDy520rgF/YObfMZvzeseH2F5UK386IXb1KYSmp8dn7RU2HvUf17Z/z1Scd -vOS4xXPNjuAi28REA72vPbFwLbt+mQxBQ/Aal6BNH5RhNIOZ9m8fVsWn/e/4hZTa -2Ib/pp/3j2D1UlJqBiAh4cBeI0QYbj/hN5+OpVUJA3+OsGzFOBhs7KfqMAP3KDTt -7sTrPV03QKKqhDjh3LIzdZyEWHPMJesMawIDAQABo4H7MIH4MA4GA1UdDwEB/wQE -AwIFoDATBgNVHSUEDDAKBggrBgEFBQcDATAMBgNVHRMBAf8EAjAAMB8GA1UdIwQY -MBaAFCJFv64s92+rdv6JGeVbQLHBxXyUMIGhBgNVHREEgZkwgZaCMm1hcmlhZGIt -b3BlcmF0b3Itd2ViaG9vay5kZWZhdWx0LnN2Yy5jbHVzdGVyLmxvY2FsgiRtYXJp -YWRiLW9wZXJhdG9yLXdlYmhvb2suZGVmYXVsdC5zdmOCIG1hcmlhZGItb3BlcmF0 -b3Itd2ViaG9vay5kZWZhdWx0ghhtYXJpYWRiLW9wZXJhdG9yLXdlYmhvb2swDQYJ -KoZIhvcNAQELBQADggEBABVoQWFqoB/wcdep9LlmWLqyVLy4Xx5mb0EhikvUKtE3 -5ChDjiiQQEYdrXsBzxLsgntIh9XFx94eX2QtjOvDUCJc3z0LLg+5c5GhWANzvB7A -G1ZUYSKs5sgS0o5oBaPt9opZqnA8WGgwZ1WR1pxRBpLmu/019vDABAUX5tV3iqVp -qYxy6XmWp5Gc7c2NqlQ9N5xsMXMSfLiUSC8O+2sJGU92GtVSp7Vt4nGg1Qh5ZyHJ -fK6S3LzTZ/HVm8nXY1e0ZnrG7SZbcJkkZgSPOjsZ9KSikdG4I9+S99FTe8X1Qzn8 -0ER77C84IUS9PEuvnSlWXopwKg5aAdHS5nHp7UFiNt4= ------END CERTIFICATE----- -` - testTLSCertBundle = ` ------BEGIN CERTIFICATE----- -MIID3DCCAsSgAwIBAgIBATANBgkqhkiG9w0BAQsFADA2MRkwFwYDVQQKExBtYXJp -YWRiLW9wZXJhdG9yMRkwFwYDVQQDExBtYXJpYWRiLW9wZXJhdG9yMB4XDTIzMTEw -NTEwMzAxNVoXDTIzMTEwNTEyMzAxNVowLzEtMCsGA1UEAxMkbWFyaWFkYi1vcGVy -YXRvci13ZWJob29rLmRlZmF1bHQuc3ZjMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8A -MIIBCgKCAQEA1l202NMTln0/ngg4JXUJLJXvhSjjHimO22c47tHhWvnzhtnKCrH8 -cWBnnxO11os5PcNIUYTxn04mZPRs+p1YkE9DMlp9Lgy/38304rr4kjllVspvl9Md -relqbcDy520rgF/YObfMZvzeseH2F5UK386IXb1KYSmp8dn7RU2HvUf17Z/z1Scd -vOS4xXPNjuAi28REA72vPbFwLbt+mQxBQ/Aal6BNH5RhNIOZ9m8fVsWn/e/4hZTa -2Ib/pp/3j2D1UlJqBiAh4cBeI0QYbj/hN5+OpVUJA3+OsGzFOBhs7KfqMAP3KDTt -7sTrPV03QKKqhDjh3LIzdZyEWHPMJesMawIDAQABo4H7MIH4MA4GA1UdDwEB/wQE -AwIFoDATBgNVHSUEDDAKBggrBgEFBQcDATAMBgNVHRMBAf8EAjAAMB8GA1UdIwQY -MBaAFCJFv64s92+rdv6JGeVbQLHBxXyUMIGhBgNVHREEgZkwgZaCMm1hcmlhZGIt -b3BlcmF0b3Itd2ViaG9vay5kZWZhdWx0LnN2Yy5jbHVzdGVyLmxvY2FsgiRtYXJp -YWRiLW9wZXJhdG9yLXdlYmhvb2suZGVmYXVsdC5zdmOCIG1hcmlhZGItb3BlcmF0 -b3Itd2ViaG9vay5kZWZhdWx0ghhtYXJpYWRiLW9wZXJhdG9yLXdlYmhvb2swDQYJ -KoZIhvcNAQELBQADggEBABVoQWFqoB/wcdep9LlmWLqyVLy4Xx5mb0EhikvUKtE3 -5ChDjiiQQEYdrXsBzxLsgntIh9XFx94eX2QtjOvDUCJc3z0LLg+5c5GhWANzvB7A -G1ZUYSKs5sgS0o5oBaPt9opZqnA8WGgwZ1WR1pxRBpLmu/019vDABAUX5tV3iqVp -qYxy6XmWp5Gc7c2NqlQ9N5xsMXMSfLiUSC8O+2sJGU92GtVSp7Vt4nGg1Qh5ZyHJ -fK6S3LzTZ/HVm8nXY1e0ZnrG7SZbcJkkZgSPOjsZ9KSikdG4I9+S99FTe8X1Qzn8 -0ER77C84IUS9PEuvnSlWXopwKg5aAdHS5nHp7UFiNt4= ------END CERTIFICATE----- ------BEGIN CERTIFICATE----- -MIID3DCCAsSgAwIBAgIBATANBgkqhkiG9w0BAQsFADA2MRkwFwYDVQQKExBtYXJp -YWRiLW9wZXJhdG9yMRkwFwYDVQQDExBtYXJpYWRiLW9wZXJhdG9yMB4XDTIzMTEw -NTEwMzAxNVoXDTIzMTEwNTEyMzAxNVowLzEtMCsGA1UEAxMkbWFyaWFkYi1vcGVy -YXRvci13ZWJob29rLmRlZmF1bHQuc3ZjMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8A -MIIBCgKCAQEA1l202NMTln0/ngg4JXUJLJXvhSjjHimO22c47tHhWvnzhtnKCrH8 -cWBnnxO11os5PcNIUYTxn04mZPRs+p1YkE9DMlp9Lgy/38304rr4kjllVspvl9Md -relqbcDy520rgF/YObfMZvzeseH2F5UK386IXb1KYSmp8dn7RU2HvUf17Z/z1Scd -vOS4xXPNjuAi28REA72vPbFwLbt+mQxBQ/Aal6BNH5RhNIOZ9m8fVsWn/e/4hZTa -2Ib/pp/3j2D1UlJqBiAh4cBeI0QYbj/hN5+OpVUJA3+OsGzFOBhs7KfqMAP3KDTt -7sTrPV03QKKqhDjh3LIzdZyEWHPMJesMawIDAQABo4H7MIH4MA4GA1UdDwEB/wQE -AwIFoDATBgNVHSUEDDAKBggrBgEFBQcDATAMBgNVHRMBAf8EAjAAMB8GA1UdIwQY -MBaAFCJFv64s92+rdv6JGeVbQLHBxXyUMIGhBgNVHREEgZkwgZaCMm1hcmlhZGIt -b3BlcmF0b3Itd2ViaG9vay5kZWZhdWx0LnN2Yy5jbHVzdGVyLmxvY2FsgiRtYXJp -YWRiLW9wZXJhdG9yLXdlYmhvb2suZGVmYXVsdC5zdmOCIG1hcmlhZGItb3BlcmF0 -b3Itd2ViaG9vay5kZWZhdWx0ghhtYXJpYWRiLW9wZXJhdG9yLXdlYmhvb2swDQYJ -KoZIhvcNAQELBQADggEBABVoQWFqoB/wcdep9LlmWLqyVLy4Xx5mb0EhikvUKtE3 -5ChDjiiQQEYdrXsBzxLsgntIh9XFx94eX2QtjOvDUCJc3z0LLg+5c5GhWANzvB7A -G1ZUYSKs5sgS0o5oBaPt9opZqnA8WGgwZ1WR1pxRBpLmu/019vDABAUX5tV3iqVp -qYxy6XmWp5Gc7c2NqlQ9N5xsMXMSfLiUSC8O+2sJGU92GtVSp7Vt4nGg1Qh5ZyHJ -fK6S3LzTZ/HVm8nXY1e0ZnrG7SZbcJkkZgSPOjsZ9KSikdG4I9+S99FTe8X1Qzn8 -0ER77C84IUS9PEuvnSlWXopwKg5aAdHS5nHp7UFiNt4= ------END CERTIFICATE----- -` - testTLSCertNoBlock = ` -MIID3DCCAsSgAwIBAgIBATANBgkqhkiG9w0BAQsFADA2MRkwFwYDVQQKExBtYXJp -YWRiLW9wZXJhdG9yMRkwFwYDVQQDExBtYXJpYWRiLW9wZXJhdG9yMB4XDTIzMTEw -NTEwMzAxNVoXDTIzMTEwNTEyMzAxNVowLzEtMCsGA1UEAxMkbWFyaWFkYi1vcGVy -YXRvci13ZWJob29rLmRlZmF1bHQuc3ZjMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8A -MIIBCgKCAQEA1l202NMTln0/ngg4JXUJLJXvhSjjHimO22c47tHhWvnzhtnKCrH8 -cWBnnxO11os5PcNIUYTxn04mZPRs+p1YkE9DMlp9Lgy/38304rr4kjllVspvl9Md -relqbcDy520rgF/YObfMZvzeseH2F5UK386IXb1KYSmp8dn7RU2HvUf17Z/z1Scd -vOS4xXPNjuAi28REA72vPbFwLbt+mQxBQ/Aal6BNH5RhNIOZ9m8fVsWn/e/4hZTa -2Ib/pp/3j2D1UlJqBiAh4cBeI0QYbj/hN5+OpVUJA3+OsGzFOBhs7KfqMAP3KDTt -7sTrPV03QKKqhDjh3LIzdZyEWHPMJesMawIDAQABo4H7MIH4MA4GA1UdDwEB/wQE -AwIFoDATBgNVHSUEDDAKBggrBgEFBQcDATAMBgNVHRMBAf8EAjAAMB8GA1UdIwQY -MBaAFCJFv64s92+rdv6JGeVbQLHBxXyUMIGhBgNVHREEgZkwgZaCMm1hcmlhZGIt -b3BlcmF0b3Itd2ViaG9vay5kZWZhdWx0LnN2Yy5jbHVzdGVyLmxvY2FsgiRtYXJp -YWRiLW9wZXJhdG9yLXdlYmhvb2suZGVmYXVsdC5zdmOCIG1hcmlhZGItb3BlcmF0 -b3Itd2ViaG9vay5kZWZhdWx0ghhtYXJpYWRiLW9wZXJhdG9yLXdlYmhvb2swDQYJ -KoZIhvcNAQELBQADggEBABVoQWFqoB/wcdep9LlmWLqyVLy4Xx5mb0EhikvUKtE3 -5ChDjiiQQEYdrXsBzxLsgntIh9XFx94eX2QtjOvDUCJc3z0LLg+5c5GhWANzvB7A -G1ZUYSKs5sgS0o5oBaPt9opZqnA8WGgwZ1WR1pxRBpLmu/019vDABAUX5tV3iqVp -qYxy6XmWp5Gc7c2NqlQ9N5xsMXMSfLiUSC8O+2sJGU92GtVSp7Vt4nGg1Qh5ZyHJ -fK6S3LzTZ/HVm8nXY1e0ZnrG7SZbcJkkZgSPOjsZ9KSikdG4I9+S99FTe8X1Qzn8 -0ER77C84IUS9PEuvnSlWXopwKg5aAdHS5nHp7UFiNt4= -` - testTLSKey = ` ------BEGIN RSA PRIVATE KEY----- -MIIEogIBAAKCAQEA1l202NMTln0/ngg4JXUJLJXvhSjjHimO22c47tHhWvnzhtnK -CrH8cWBnnxO11os5PcNIUYTxn04mZPRs+p1YkE9DMlp9Lgy/38304rr4kjllVspv -l9MdrelqbcDy520rgF/YObfMZvzeseH2F5UK386IXb1KYSmp8dn7RU2HvUf17Z/z -1ScdvOS4xXPNjuAi28REA72vPbFwLbt+mQxBQ/Aal6BNH5RhNIOZ9m8fVsWn/e/4 -hZTa2Ib/pp/3j2D1UlJqBiAh4cBeI0QYbj/hN5+OpVUJA3+OsGzFOBhs7KfqMAP3 -KDTt7sTrPV03QKKqhDjh3LIzdZyEWHPMJesMawIDAQABAoIBAEw5tf0D0YtJrj17 -nrtzCngYOLuY9mnbTTknU09YwlGfX8Er4HQ9Jg8KwM4ILDjF+OzFbAnQxDpph62O -XNIg8UUfaj2Vf73IOtJSYindYlZconRiN5w9LeiRf47XdYhlgXp8ml6rxLs6X9XR -C7kG/n7m6garMK+sKQofAQJ7tzDOpzna/V+C3z7vwupmypjVmKuqFfDOITUhk06Q -WNUbiBSRbVtuWJ4+xpg5cZbCSIzlziQFbaRjQ0bHVIoJ/DrHI8OCEELjcQi7aMPX -Z6XZnpVteae5o5lpw1AbB7c4ALF5B4Z1RQKakb+mK7o4F6SV/TOf2Fb7Gwp5gSOO -Udo/acECgYEA7woaxJvkyJH6G83kt73RUudFbKIUo/lv8E0BLcN/7jo8zVMQIoxN -m6fffVWfy2zVd5rLus4wjq+f5jmAprJykkA7n3HIM6HJ2v9KJPVCBKA8bgJ5eU0Y -c7VFz8O0Z8dj/p9oVrHLo0Aqo+69rXG6wcyck1au3FEw16jflU1MaMUCgYEA5ZNv -fHHDtWDjCPM75N7aaw2C9ENL3fi8Fwh/h4ZRj/BftHnHZkjY3bpW1/17lXuTnbE6 -uHeq3s/wBt4+F/N61ps1a7NTOtZHst5fPZZUjWuT0vq0EN2KB81iR/N4ld7w3F3v -bZSPNXpm1J3wSAVrL1tHdQdWXdkOTeTA4wAnE28CgYANZKuLSJDRDBzPYgHmqaQI -2RxyscImTduPw0DFp6aLWof9mSHWTbYreoRzKVECvN5ZDTtNBDCETiLPa3lh3a29 -tAujK2TkP7RnqNYmq/c++xtnrovP2Bn+obF/qp95ERrxMU1PTjbytq2s8bt+9Fha -c3RybPDvNz1dWADvBJ27YQKBgAF6b49XlDEIzK10E4CnxrRFxAAaptRpE5z6Wwfe -X4wTuioJVrVb5rmWx5Rgd3lA8HRlfcFOU/VXVW5V5AR3duUG3tMwtmp8kr2eHPLi -kuzOMod7QcmSA5+FPQrFkJM2ekqQ+Ee2Wy22+g6IbdGo50XIyq8AOxgjm6n4vR05 -FQdVAoGAUt0hi789SX+BKzkWWMnds8HZzO3a5OgN9on+Hd/BVaQG6+F4wBGyjdjz -PEZhWvsvx9Qge+DhwW3vfTDH2RItevD5Av4x0jZX0TWPoII8aP07VmDQ4g0cEkKh -nBZoGLkeDofSc+Ml4HRpi43U+fqhU77wr8Gq0YU74h7lFfiRI/M= ------END RSA PRIVATE KEY----- -` -) - -func TestKeyPairFromTLSSecret(t *testing.T) { - secret := corev1.Secret{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test", - }, - Type: corev1.SecretTypeTLS, - Data: map[string][]byte{ - "tls.crt": []byte(testTLSCert), - "tls.key": []byte(testTLSKey), - }, - } - - keyPair, err := KeyPairFromTLSSecret(&secret) - if err != nil { - t.Fatalf("Unexpected error creating KeyPair from TLS Secret: %v", err) - } - if keyPair == nil { - t.Fatal("KeyPair should nit be nul") - } - expectedCN := "mariadb-operator-webhook.default.svc" - if expectedCN != keyPair.Cert.Subject.CommonName { - t.Fatalf("Expected CommonName to be %v. Got %v", expectedCN, keyPair.Cert.Subject.CommonName) - } -} - -func TestKeyPairInvalidPEM(t *testing.T) { - _, err := KeyPairFromPEM([]byte("foo"), []byte("bar")) - if err == nil { - t.Fatal("Expected KeyPair creation to fail") - } -} - -func TestCACert(t *testing.T) { - caName := "test-mariadb-operator" - x509Opts := []X509Opt{ - WithCommonName(caName), - WithOrganization("test-org"), - WithNotBefore(time.Now()), - WithNotAfter(time.Now().Add(24 * time.Hour)), - } - caKeyPair, err := CreateCA(x509Opts...) - if err != nil { - t.Fatalf("CA cert creation should succeed. Got error: %v", err) - } - - valid, err := ValidCACert(caKeyPair, caName, time.Now()) - if err != nil { - t.Fatalf("CA cert validation should succeed. Got error: %v", err) - } - if !valid { - t.Fatal("Expected CA cert to be valid") - } - - valid, err = ValidCACert(caKeyPair, caName, time.Now().Add(-1*time.Hour)) - if err == nil { - t.Fatalf("CA cert validation should return an error. Got nil") - } - if valid { - t.Fatal("Expected CA cert to be invalid") - } - - valid, err = ValidCACert(caKeyPair, "foo", time.Now()) - if err == nil { - t.Fatalf("CA cert validation should return an error. Got nil") - } - if valid { - t.Fatal("Expected CA cert to be invalid") - } - - caKeyPair, err = CreateCA(x509Opts...) - if err != nil { - t.Fatalf("CA cert renewal should succeed. Got error: %v", err) - } - - valid, err = ValidCACert(caKeyPair, caName, time.Now()) - if err != nil { - t.Fatalf("CA cert validation should succeed after renewal. Got error: %v", err) - } - if !valid { - t.Fatal("Expected CA cert to be valid after renewal") - } -} - -func TestCert(t *testing.T) { - caKeyPair, err := CreateCA() - if err != nil { - t.Fatalf("CA cert creation should succeed. Got error: %v", err) - } - - commonName := "mariadb-operator.default.svc" - x509Opts := []X509Opt{ - WithCommonName(commonName), - WithDNSNames([]string{ - "mariadb-operator", - "mariadb-operator.default", - commonName, - }), - WithNotBefore(time.Now()), - WithNotAfter(time.Now().Add(24 * time.Hour)), - } - keyPairPEM, err := CreateCert(caKeyPair, x509Opts...) - if err != nil { - t.Fatalf("Certificate creation should succeed. Got error: %v", err) - } - - valid, err := ValidCert(caKeyPair.Cert, keyPairPEM, commonName, time.Now()) - if err != nil { - t.Fatalf("Cert validation should succeed. Got error: %v", err) - } - if !valid { - t.Fatal("Expected cert to be valid") - } - - valid, err = ValidCert(caKeyPair.Cert, keyPairPEM, commonName, time.Now().Add(-1*time.Hour)) - if err == nil { - t.Fatalf("Cert validation should return an error. Got nil") - } - if valid { - t.Fatal("Expected cert to be invalid") - } - - valid, err = ValidCert(caKeyPair.Cert, keyPairPEM, "foo", time.Now()) - if err == nil { - t.Fatalf("Cert validation should return an error. Got nil") - } - if valid { - t.Fatal("Expected cert to be invalid") - } - - keyPairPEM, err = CreateCert(caKeyPair, x509Opts...) - if err != nil { - t.Fatalf("Certificate renewal should succeed. Got error: %v", err) - } - - valid, err = ValidCert(caKeyPair.Cert, keyPairPEM, commonName, time.Now()) - if err != nil { - t.Fatalf("Cert validation should succeed after renewal. Got error: %v", err) - } - if !valid { - t.Fatal("Expected cert to be valid") - } -} - -func TestParseCert(t *testing.T) { - tests := []struct { - name string - certBytes []byte - wantErr bool - }{ - { - name: "Valid cert", - certBytes: []byte(testTLSCert), - wantErr: false, - }, - { - name: "Valid cert bundle", - certBytes: []byte(testTLSCertBundle), - wantErr: false, - }, - { - name: "No block cert", - certBytes: []byte(testTLSCertNoBlock), - wantErr: true, - }, - { - name: "Invalid cert", - certBytes: []byte("foo"), - wantErr: true, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - _, err := ParseCert(tt.certBytes) - if tt.wantErr && err == nil { - t.Fatalf("Expecting error to be non nil when parsing '%s'", tt.name) - } - if !tt.wantErr && err != nil { - t.Fatalf("Expecting error to be nil when parsing '%s'. Got: %v", tt.name, err) - } - }) - } -} diff --git a/pkg/pki/private_key.go b/pkg/pki/private_key.go new file mode 100644 index 0000000000..1b11a21d7a --- /dev/null +++ b/pkg/pki/private_key.go @@ -0,0 +1,75 @@ +package pki + +import ( + "crypto" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + "encoding/pem" + "errors" + "fmt" + + ds "github.com/mariadb-operator/mariadb-operator/pkg/datastructures" +) + +// PrivateKey represents a type of private key. +type PrivateKey string + +const ( + // PrivateKeyTypeECDSA represents an ECDSA private key. + PrivateKeyTypeECDSA PrivateKey = "ecdsa" + // PrivateKeyTypeRSA represents an RSA private key. + PrivateKeyTypeRSA PrivateKey = "rsa" +) + +// GeneratePrivateKey generates a new ECDSA private key. +func GeneratePrivateKey() (crypto.Signer, error) { + privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + return nil, fmt.Errorf("error generating ECDSA private key: %v", err) + } + return privateKey, nil +} + +// MarshalPrivateKey marshals the given ECDSA private key to bytes. +func MarshalPrivateKey(signer crypto.Signer) ([]byte, error) { + privateKey, ok := signer.(*ecdsa.PrivateKey) + if !ok { + return nil, errors.New("signer is not an ECDSA private key") + } + return x509.MarshalECPrivateKey(privateKey) +} + +// ParsePrivateKey parses a private key from the given bytes. +func ParsePrivateKey(bytes []byte, supportedKeys []PrivateKey) (crypto.Signer, error) { + block, _ := pem.Decode(bytes) // private key should only have a single block + if block == nil { + return nil, errors.New("error decoding PEM") + } + if !isSupportedPrivateKeyBlock(block.Type, supportedKeys) { + return nil, fmt.Errorf("unsupported PEM block: %v", block.Type) + } + + switch block.Type { + case pemBlockECPrivateKey: + return x509.ParseECPrivateKey(block.Bytes) + case pemBlockRSAPrivateKey: + return x509.ParsePKCS1PrivateKey(block.Bytes) // backwards compatibility with webhook certs from previous versions + default: + return nil, fmt.Errorf("unsupported PEM block type: %v", block.Type) + } +} + +func isSupportedPrivateKeyBlock(block string, supportedKeys []PrivateKey) bool { + idx := ds.NewIndex(supportedKeys, func(pk PrivateKey) string { + return string(pk) + }) + if block == pemBlockECPrivateKey && ds.Has(idx, string(PrivateKeyTypeECDSA)) { + return true + } + if block == pemBlockRSAPrivateKey && ds.Has(idx, string(PrivateKeyTypeRSA)) { + return true + } + return false +} diff --git a/pkg/pki/private_key_test.go b/pkg/pki/private_key_test.go new file mode 100644 index 0000000000..6dbf6207f7 --- /dev/null +++ b/pkg/pki/private_key_test.go @@ -0,0 +1,102 @@ +package pki + +import ( + "crypto/ecdsa" + "testing" +) + +func TestPrivateKey(t *testing.T) { + privateKey, err := GeneratePrivateKey() + if err != nil { + t.Fatalf("unexpected error generating private key: %v", err) + } + + bytes, err := MarshalPrivateKey(privateKey) + if err != nil { + t.Fatalf("unexpected error marshaling private key: %v", err) + } + + pemKey, err := pemEncodePrivateKey(bytes, privateKey) + if err != nil { + t.Fatalf("unexpected error encoding private key: %v", err) + } + + parsedKey, err := ParsePrivateKey(pemKey, []PrivateKey{PrivateKeyTypeECDSA}) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if _, ok := parsedKey.(*ecdsa.PrivateKey); !ok { + t.Fatalf("expected *ecdsa.PrivateKey, got %T", parsedKey) + } +} + +func TestParsePrivateKey(t *testing.T) { + tests := []struct { + name string + pemKey []byte + keyTypes []PrivateKey + wantErr bool + }{ + { + name: "Invalid", + pemKey: []byte("invalid"), + wantErr: true, + }, + { + name: "Unsupported", + pemKey: []byte(`-----BEGIN RSA PRIVATE KEY----- +MIIEogIBAAKCAQEA1l202NMTln0/ngg4JXUJLJXvhSjjHimO22c47tHhWvnzhtnK +CrH8cWBnnxO11os5PcNIUYTxn04mZPRs+p1YkE9DMlp9Lgy/38304rr4kjllVspv +l9MdrelqbcDy520rgF/YObfMZvzeseH2F5UK386IXb1KYSmp8dn7RU2HvUf17Z/z +1ScdvOS4xXPNjuAi28REA72vPbFwLbt+mQxBQ/Aal6BNH5RhNIOZ9m8fVsWn/e/4 +hZTa2Ib/pp/3j2D1UlJqBiAh4cBeI0QYbj/hN5+OpVUJA3+OsGzFOBhs7KfqMAP3 +KDTt7sTrPV03QKKqhDjh3LIzdZyEWHPMJesMawIDAQABAoIBAEw5tf0D0YtJrj17 +nrtzCngYOLuY9mnbTTknU09YwlGfX8Er4HQ9Jg8KwM4ILDjF+OzFbAnQxDpph62O +XNIg8UUfaj2Vf73IOtJSYindYlZconRiN5w9LeiRf47XdYhlgXp8ml6rxLs6X9XR +C7kG/n7m6garMK+sKQofAQJ7tzDOpzna/V+C3z7vwupmypjVmKuqFfDOITUhk06Q +WNUbiBSRbVtuWJ4+xpg5cZbCSIzlziQFbaRjQ0bHVIoJ/DrHI8OCEELjcQi7aMPX +Z6XZnpVteae5o5lpw1AbB7c4ALF5B4Z1RQKakb+mK7o4F6SV/TOf2Fb7Gwp5gSOO +Udo/acECgYEA7woaxJvkyJH6G83kt73RUudFbKIUo/lv8E0BLcN/7jo8zVMQIoxN +m6fffVWfy2zVd5rLus4wjq+f5jmAprJykkA7n3HIM6HJ2v9KJPVCBKA8bgJ5eU0Y +c7VFz8O0Z8dj/p9oVrHLo0Aqo+69rXG6wcyck1au3FEw16jflU1MaMUCgYEA5ZNv +fHHDtWDjCPM75N7aaw2C9ENL3fi8Fwh/h4ZRj/BftHnHZkjY3bpW1/17lXuTnbE6 +uHeq3s/wBt4+F/N61ps1a7NTOtZHst5fPZZUjWuT0vq0EN2KB81iR/N4ld7w3F3v +bZSPNXpm1J3wSAVrL1tHdQdWXdkOTeTA4wAnE28CgYANZKuLSJDRDBzPYgHmqaQI +2RxyscImTduPw0DFp6aLWof9mSHWTbYreoRzKVECvN5ZDTtNBDCETiLPa3lh3a29 +tAujK2TkP7RnqNYmq/c++xtnrovP2Bn+obF/qp95ERrxMU1PTjbytq2s8bt+9Fha +c3RybPDvNz1dWADvBJ27YQKBgAF6b49XlDEIzK10E4CnxrRFxAAaptRpE5z6Wwfe +X4wTuioJVrVb5rmWx5Rgd3lA8HRlfcFOU/VXVW5V5AR3duUG3tMwtmp8kr2eHPLi +kuzOMod7QcmSA5+FPQrFkJM2ekqQ+Ee2Wy22+g6IbdGo50XIyq8AOxgjm6n4vR05 +FQdVAoGAUt0hi789SX+BKzkWWMnds8HZzO3a5OgN9on+Hd/BVaQG6+F4wBGyjdjz +PEZhWvsvx9Qge+DhwW3vfTDH2RItevD5Av4x0jZX0TWPoII8aP07VmDQ4g0cEkKh +nBZoGLkeDofSc+Ml4HRpi43U+fqhU77wr8Gq0YU74h7lFfiRI/M= +-----END RSA PRIVATE KEY----- +`), + wantErr: true, + }, + { + name: "Valid", + pemKey: []byte(`-----BEGIN EC PRIVATE KEY----- +MHcCAQEEIAdp3iKnNA1kO2Ep5Hw7owMcm06SecFGdOqW/vO4k2AjoAoGCCqGSM49 +AwEHoUQDQgAEiTVhkriBksuWW5W3Mv9L918m1BECaHUl7ZV/Pz2q84wY9aEbxe2P +J3c22DtEFzg9emNuruVS5/HL+hanzz4o+g== +-----END EC PRIVATE KEY----- +`), + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + parsedKey, err := ParsePrivateKey(tt.pemKey, []PrivateKey{PrivateKeyTypeECDSA}) + if (err != nil) != tt.wantErr { + t.Fatalf("expected error: %v, got: %v", tt.wantErr, err) + } + if !tt.wantErr { + if _, ok := parsedKey.(*ecdsa.PrivateKey); !ok { + t.Fatalf("expected *ecdsa.PrivateKey, got %T", parsedKey) + } + } + }) + } +} diff --git a/pkg/pki/renewal.go b/pkg/pki/renewal.go new file mode 100644 index 0000000000..0fcc46f51e --- /dev/null +++ b/pkg/pki/renewal.go @@ -0,0 +1,45 @@ +package pki + +import ( + "fmt" + "time" +) + +// DefaultRenewBeforePercentage is the default percentage to calculate the renewal duration. +var DefaultRenewBeforePercentage = int32(33) // 33% + +// RenewalDuration calculates the certificate renewal duration based on a given duration and a specified percentage. +// The percentage determines the fraction of the duration before the expiration when renewal should occur. +func RenewalDuration(duration time.Duration, renewBeforePercentage int32) (*time.Duration, error) { + if err := validateRenewBeforePercentage(renewBeforePercentage); err != nil { + return nil, err + } + // See https://github.com/cert-manager/cert-manager/blob/dd8b7d233110cbd49f2f31eb709f39865f8b0300/pkg/util/pki/renewaltime.go#L71 + renewalDuration := duration * time.Duration(renewBeforePercentage) / 100 + + return &renewalDuration, nil +} + +// RenewalTime calculates the renewal time for a fraction based on its lifetime. +// The percentage determines the fraction of the validity period before expiration when renewal should occur. +func RenewalTime(notBefore, notAfter time.Time, renewBeforePercentage int32) (*time.Time, error) { + if err := validateRenewBeforePercentage(renewBeforePercentage); err != nil { + return nil, err + } + duration := notAfter.Sub(notBefore) + renewalDuration, err := RenewalDuration(duration, renewBeforePercentage) + if err != nil { + return nil, fmt.Errorf("error getting renewal duration: %v", err) + } + // See https://github.com/cert-manager/cert-manager/blob/dd8b7d233110cbd49f2f31eb709f39865f8b0300/pkg/util/pki/renewaltime.go#L53 + renewalTime := notAfter.Add(-1 * *renewalDuration).Truncate(time.Second) + + return &renewalTime, nil +} + +func validateRenewBeforePercentage(renewBeforePercentage int32) error { + if !(renewBeforePercentage >= 10 && renewBeforePercentage <= 90) { + return fmt.Errorf("invalid renewBeforePercentage %v, it must be between [10, 90]", renewBeforePercentage) + } + return nil +} diff --git a/pkg/pki/renewal_test.go b/pkg/pki/renewal_test.go new file mode 100644 index 0000000000..cc8f6a38fd --- /dev/null +++ b/pkg/pki/renewal_test.go @@ -0,0 +1,129 @@ +package pki + +import ( + "testing" + "time" +) + +func TestRenewalDuration(t *testing.T) { + tests := []struct { + name string + duration time.Duration + renewBeforePercentage int32 + expectedDuration time.Duration + expectError bool + }{ + { + name: "invalid percentage zero", + duration: 24 * time.Hour, + renewBeforePercentage: 0, + expectedDuration: 0, + expectError: true, + }, + { + name: "invalid percentage 100", + duration: 24 * time.Hour, + renewBeforePercentage: 100, + expectedDuration: 0, + expectError: true, + }, + { + name: "50% of 1 day", + duration: 24 * time.Hour, + renewBeforePercentage: 50, + expectedDuration: 12 * time.Hour, + expectError: false, + }, + { + name: "30% of 3 months", + duration: 3 * 730 * time.Hour, // 3 months + renewBeforePercentage: 30, + expectedDuration: 3 * 219 * time.Hour, // 30% of 3 months + expectError: false, + }, + { + name: "30% of 3 years", + duration: 3 * 12 * 730 * time.Hour, // 3 years + renewBeforePercentage: 30, + expectedDuration: 3 * 12 * 219 * time.Hour, // 30% of 3 years + expectError: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + renewalDuration, err := RenewalDuration(tt.duration, tt.renewBeforePercentage) + if (err != nil) != tt.expectError { + t.Errorf("expected error: %v, got: %v", tt.expectError, err) + } + if !tt.expectError && *renewalDuration != tt.expectedDuration { + t.Errorf("expected renewal duration: %v, got: %v", tt.expectedDuration, *renewalDuration) + } + }) + } +} + +func TestRenewalTime(t *testing.T) { + now := time.Now() + tests := []struct { + name string + notBefore time.Time + notAfter time.Time + renewBeforePercentage int32 + expectedRenewalTime time.Time + expectError bool + }{ + { + name: "invalid percentage zero", + notBefore: now, + notAfter: now.Add(24 * time.Hour), + renewBeforePercentage: 0, + expectedRenewalTime: time.Time{}, + expectError: true, + }, + { + name: "invalid percentage 100", + notBefore: now, + notAfter: now.Add(24 * time.Hour), + renewBeforePercentage: 100, + expectedRenewalTime: time.Time{}, + expectError: true, + }, + { + name: "50% of 1 day", + notBefore: now, + notAfter: now.Add(24 * time.Hour), + renewBeforePercentage: 50, + expectedRenewalTime: now.Add(12 * time.Hour), + expectError: false, + }, + { + name: "30% of 3 months", + notBefore: now, + notAfter: now.Add(3 * 730 * time.Hour), // 3 months + renewBeforePercentage: 30, + expectedRenewalTime: now.Add(3 * 511 * time.Hour), // 70% of 3 months + expectError: false, + }, + { + name: "30% of 3 years", + notBefore: now, + notAfter: now.Add(3 * 12 * 730 * time.Hour), // 3 years + renewBeforePercentage: 30, + expectedRenewalTime: now.Add(3 * 12 * 511 * time.Hour), // 70% of 3 years + expectError: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + renewalTime, err := RenewalTime(tt.notBefore, tt.notAfter, tt.renewBeforePercentage) + if (err != nil) != tt.expectError { + t.Errorf("expected error: %v, got: %v", tt.expectError, err) + } + if !tt.expectError && !renewalTime.Truncate(time.Second).Equal(tt.expectedRenewalTime.Truncate(time.Second)) { + t.Errorf("expected renewal time: %v, got: %v", tt.expectedRenewalTime, renewalTime) + } + }) + } +} diff --git a/pkg/pki/x509.go b/pkg/pki/x509.go new file mode 100644 index 0000000000..e06fb51739 --- /dev/null +++ b/pkg/pki/x509.go @@ -0,0 +1,332 @@ +package pki + +import ( + "crypto/rand" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "errors" + "fmt" + "math/big" + "time" +) + +var ( + DefaultCALifetime = 3 * 365 * 24 * time.Hour // 3 years + DefaultCertLifetime = 3 * 30 * 24 * time.Hour // 3 months + + caMinLifetime = 1 * time.Hour + caMaxLifetime = 10 * 365 * 24 * time.Hour // 10 years + + certMinLifetime = 1 * time.Hour + certMaxLifetime = 3 * 365 * 24 * time.Hour // 3 years +) + +// X509Opts represents options for creating X.509 certificates. +type X509Opts struct { + // CommonName is the common name for the certificate. + CommonName string + // DNSNames is a list of DNS names for the certificate. + DNSNames []string + // NotBefore is the start time for the certificate's validity period. + NotBefore time.Time + // NotAfter is the end time for the certificate's validity period. + NotAfter time.Time + // KeyUsage specifies the allowed uses of the key. + KeyUsage x509.KeyUsage + // ExtKeyUsage specifies the extended key usages of the certificate. + ExtKeyUsage []x509.ExtKeyUsage + // IsCA indicates whether the certificate is a CA certificate. + IsCA bool + // KeyPairOpts are options to configure the keypair. + KeyPairOpts []KeyPairOpt +} + +// X509Opt is a function type used to configure X509Opts. +type X509Opt func(*X509Opts) + +// WithCommonName sets the common name for the certificate. +func WithCommonName(name string) X509Opt { + return func(x *X509Opts) { + x.CommonName = name + } +} + +// WithDNSNames sets the DNS names for the certificate. +func WithDNSNames(dnsNames ...string) X509Opt { + return func(x *X509Opts) { + x.DNSNames = dnsNames + } +} + +// WithNotBefore sets the start time for the certificate's validity period. +func WithNotBefore(notBefore time.Time) X509Opt { + return func(x *X509Opts) { + x.NotBefore = notBefore + } +} + +// WithNotAfter sets the end time for the certificate's validity period. +func WithNotAfter(notAfter time.Time) X509Opt { + return func(x *X509Opts) { + x.NotAfter = notAfter + } +} + +// WithKeyUsage sets the key usage for the certificate. +func WithKeyUsage(keyUsage x509.KeyUsage) X509Opt { + return func(x *X509Opts) { + x.KeyUsage |= keyUsage + } +} + +// WithExtKeyUsage sets the extended key usages for the certificate. +func WithExtKeyUsage(extKeyUsage ...x509.ExtKeyUsage) X509Opt { + return func(x *X509Opts) { + x.ExtKeyUsage = append(x.ExtKeyUsage, extKeyUsage...) + } +} + +// WithIsCA sets whether the certificate is a CA certificate. +func WithIsCA(isCA bool) X509Opt { + return func(x *X509Opts) { + x.IsCA = isCA + } +} + +// WithKeyPairOpts sets options to configure the keypair. +func WithKeyPairOpts(keyPairOpts ...KeyPairOpt) X509Opt { + return func(x *X509Opts) { + x.KeyPairOpts = keyPairOpts + } +} + +// CreateCA creates a new CA certificate with the given options. +func CreateCA(x509Opts ...X509Opt) (*KeyPair, error) { + opts := X509Opts{ + NotBefore: time.Now().Add(-1 * time.Hour), + NotAfter: time.Now().Add(DefaultCALifetime), + } + for _, setOpt := range x509Opts { + setOpt(&opts) + } + if opts.CommonName == "" { + return nil, errors.New("CommonName is mandatory") + } + if err := validateLifetime(opts.NotBefore, opts.NotAfter, caMinLifetime, caMaxLifetime); err != nil { + return nil, fmt.Errorf("invalid CA lifetime: %v", err) + } + + serialNumber, err := getSerialNumber() + if err != nil { + return nil, fmt.Errorf("error getting serial number: %v", err) + } + + tpl := &x509.Certificate{ + SerialNumber: serialNumber, + Subject: pkix.Name{ + CommonName: opts.CommonName, + }, + DNSNames: []string{ + opts.CommonName, + }, + NotBefore: opts.NotBefore, + NotAfter: opts.NotAfter, + KeyUsage: x509.KeyUsageCertSign, + BasicConstraintsValid: true, + IsCA: true, + } + return NewKeyPairFromTemplate(tpl, nil, opts.KeyPairOpts...) +} + +// CreateCert creates a new certificate signed by the given CA key pair with the given options. +func CreateCert(caKeyPair *KeyPair, x509Opts ...X509Opt) (*KeyPair, error) { + opts := X509Opts{ + NotBefore: time.Now().Add(-1 * time.Hour), + NotAfter: time.Now().Add(DefaultCertLifetime), + KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyAgreement, + IsCA: false, + } + for _, setOpt := range x509Opts { + setOpt(&opts) + } + if opts.CommonName == "" || opts.DNSNames == nil { + return nil, errors.New("CommonName and DNSNames are mandatory") + } + if err := validateLifetime(opts.NotBefore, opts.NotAfter, certMinLifetime, certMaxLifetime); err != nil { + return nil, fmt.Errorf("invalid certificate lifetime: %v", err) + } + + serialNumber, err := getSerialNumber() + if err != nil { + return nil, fmt.Errorf("error getting serial number: %v", err) + } + + tpl := &x509.Certificate{ + SerialNumber: serialNumber, + Subject: pkix.Name{ + CommonName: opts.CommonName, + }, + DNSNames: opts.DNSNames, + NotBefore: opts.NotBefore, + NotAfter: opts.NotAfter, + KeyUsage: opts.KeyUsage, + ExtKeyUsage: opts.ExtKeyUsage, + BasicConstraintsValid: true, + IsCA: opts.IsCA, + } + return NewKeyPairFromTemplate(tpl, caKeyPair, opts.KeyPairOpts...) +} + +// ValidateCA validates the given CA key pair at the specified time. +func ValidateCA(keyPair *KeyPair, dnsName string, at time.Time) (bool, error) { + certs, err := keyPair.Certificates() + if err != nil { + return false, fmt.Errorf("error getting certificates: %v", err) + } + return ValidateCert(certs, keyPair, dnsName, at) +} + +// ValidateCertOpts represents options for validating certificates. +type ValidateCertOpts struct { + intermediateCAs []*x509.Certificate +} + +// ValidateCertOpt is a function type used to configure ValidateCertOpts. +type ValidateCertOpt func(*ValidateCertOpts) + +// WithIntermediateCAs sets the intermediate CAs for certificate validation. +func WithIntermediateCAs(intermediateCAs ...*x509.Certificate) ValidateCertOpt { + return func(vco *ValidateCertOpts) { + vco.intermediateCAs = intermediateCAs + } +} + +// ValidateCert validates the given certificate key pair against the provided CA certificates at the specified time. +func ValidateCert( + caCerts []*x509.Certificate, + certKeyPair *KeyPair, + dnsName string, + at time.Time, + validateCertOpts ...ValidateCertOpt, +) (bool, error) { + if len(caCerts) == 0 { + return false, errors.New("CA certicates should be provided to establish trust") + } + if err := certKeyPair.Validate(); err != nil { + return false, fmt.Errorf("invalid keypair: %v", err) + } + + leafCert, err := certKeyPair.LeafCertificate() + if err != nil { + return false, fmt.Errorf("error getting leaf certificate: %v", err) + } + certs, err := certKeyPair.Certificates() + if err != nil { + return false, fmt.Errorf("error getting certificates: %v", err) + } + + var intermediateCAs []*x509.Certificate + if len(certs) > 1 { + intermediateCAs = certs[1:] // intermediate certificates, if present, form the chain leading to a trusted root CA + } + + opts := ValidateCertOpts{} + for _, setOpt := range validateCertOpts { + setOpt(&opts) + } + + rootCAsPool := x509.NewCertPool() + for _, cert := range caCerts { + rootCAsPool.AddCert(cert) + } + intermediateCAsPool := x509.NewCertPool() + for _, cert := range intermediateCAs { + intermediateCAsPool.AddCert(cert) + } + // explicitly provided intermediate CAs to form the chain leading to a trusted root CA + for _, cert := range opts.intermediateCAs { + intermediateCAsPool.AddCert(cert) + } + + _, err = leafCert.Verify(x509.VerifyOptions{ + Roots: rootCAsPool, + Intermediates: intermediateCAsPool, + DNSName: dnsName, + KeyUsages: []x509.ExtKeyUsage{ + x509.ExtKeyUsageServerAuth, + x509.ExtKeyUsageClientAuth, + }, + CurrentTime: at, + }) + if err != nil { + return false, err + } + return true, nil +} + +// ParseCertificate parses a single certificate from the given bytes. +func ParseCertificate(bytes []byte) (*x509.Certificate, error) { + certs, err := ParseCertificates(bytes) + if err != nil { + return nil, err + } + return certs[0], nil +} + +// ParseCertificates parses multiple certificates from the given bytes. +func ParseCertificates(bytes []byte) ([]*x509.Certificate, error) { + var ( + certs []*x509.Certificate + block *pem.Block + ) + pemBytes := bytes + + for len(pemBytes) > 0 { + block, pemBytes = pem.Decode(pemBytes) + if block == nil { + return nil, errors.New("invalid PEM block") + } + if block.Type != pemBlockCertificate { + return nil, fmt.Errorf("invalid PEM certificate block, got block type: %v", block.Type) + } + + cert, err := x509.ParseCertificate(block.Bytes) + if err != nil { + return nil, err + } + certs = append(certs, cert) + } + if len(certs) == 0 { + return nil, errors.New("no valid certificates found") + } + + return certs, nil +} + +func validateLifetime(notBefore, notAfter time.Time, minDuration, maxDuration time.Duration) error { + if notBefore.After(notAfter) { + return fmt.Errorf("NotBefore (%v) cannot be after NotAfter (%v)", notBefore, notAfter) + } + + duration := notAfter.Sub(notBefore) + if duration < minDuration { + return fmt.Errorf("lifetime duration (%v) is less than the minimum allowed duration (%v)", duration, minDuration) + } + + if duration > maxDuration { + return fmt.Errorf("lifetime duration (%v) exceeds the maximum allowed duration (%v)", duration, maxDuration) + } + + return nil +} + +var serialNumberLimit = new(big.Int).Lsh(big.NewInt(1), 128) + +func getSerialNumber() (*big.Int, error) { + serialNumber, err := rand.Int(rand.Reader, serialNumberLimit) + if err != nil { + return nil, err + } + return serialNumber, nil +} diff --git a/pkg/pki/x509_test.go b/pkg/pki/x509_test.go new file mode 100644 index 0000000000..20c26563b5 --- /dev/null +++ b/pkg/pki/x509_test.go @@ -0,0 +1,744 @@ +package pki + +import ( + "crypto/x509" + "reflect" + "testing" + "time" +) + +func TestCreateCA(t *testing.T) { + testCreateCert( + t, + []testCaseCreateCert{ + { + name: "No CommonName", + x509Opts: []X509Opt{}, + wantErr: true, + }, + { + name: "Invalid Lifetime", + x509Opts: []X509Opt{ + WithNotBefore(time.Now().Add(2 * time.Hour)), + WithNotAfter(time.Now().Add(1 * time.Hour)), + }, + wantErr: true, + }, + { + name: "Valid", + x509Opts: []X509Opt{ + WithCommonName("test"), + }, + wantErr: false, + wantCommonName: "test", + wantIssuer: "test", + wantDNSNames: []string{"test"}, + wantKeyUsage: x509.KeyUsageCertSign, + wantIsCA: true, + }, + { + name: "Custom Lifetime", + x509Opts: []X509Opt{ + WithCommonName("test"), + WithNotBefore(time.Now().Add(-2 * time.Hour)), + WithNotAfter(time.Now().Add(5 * 365 * 24 * time.Hour)), + }, + wantErr: false, + wantCommonName: "test", + wantIssuer: "test", + wantDNSNames: []string{"test"}, + wantKeyUsage: x509.KeyUsageCertSign, + wantIsCA: true, + }, + }, + CreateCA, + ValidateCA, + ) +} + +func TestCreateCert(t *testing.T) { + caName := "tetst" + caKeyPair, err := CreateCA( + WithCommonName(caName), + ) + if err != nil { + t.Fatalf("CA cert creation should succeed. Got error: %v", err) + } + caCerts, err := caKeyPair.Certificates() + if err != nil { + t.Fatalf("Unable to get CA certificates: %v", err) + } + + testCreateCert( + t, + []testCaseCreateCert{ + { + name: "Missing CommonName", + x509Opts: []X509Opt{ + WithDNSNames("missing-common-name"), + }, + wantErr: true, + }, + { + name: "Missing DNSNames", + x509Opts: []X509Opt{ + WithCommonName("missing-dns-names"), + }, + wantErr: true, + }, + { + name: "Invalid Lifetime", + x509Opts: []X509Opt{ + WithCommonName("invalid-lifetime"), + WithDNSNames("invalid-lifetime"), + WithNotBefore(time.Now().Add(2 * time.Hour)), + WithNotAfter(time.Now().Add(1 * time.Hour)), + }, + wantErr: true, + }, + { + name: "Default Cert", + x509Opts: []X509Opt{ + WithCommonName("default-cert"), + WithDNSNames("default-cert"), + }, + wantErr: false, + wantCommonName: "default-cert", + wantIssuer: caName, + wantDNSNames: []string{"default-cert"}, + wantKeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyAgreement, + wantIsCA: false, + }, + { + name: "Custom Key Usage", + x509Opts: []X509Opt{ + WithCommonName("custom-key-usage"), + WithDNSNames("custom-key-usage"), + WithKeyUsage(x509.KeyUsageKeyEncipherment), + }, + wantErr: false, + wantCommonName: "custom-key-usage", + wantIssuer: caName, + wantDNSNames: []string{"custom-key-usage"}, + wantKeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyAgreement | x509.KeyUsageKeyEncipherment, + wantIsCA: false, + }, + { + name: "Custom Ext Key Usage", + x509Opts: []X509Opt{ + WithCommonName("custom-ext-key-usage"), + WithDNSNames("custom-ext-key-usage"), + WithExtKeyUsage(x509.ExtKeyUsageClientAuth, x509.ExtKeyUsageServerAuth), + }, + wantErr: false, + wantCommonName: "custom-ext-key-usage", + wantIssuer: caName, + wantDNSNames: []string{"custom-ext-key-usage"}, + wantKeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyAgreement, + wantExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth, x509.ExtKeyUsageServerAuth}, + wantIsCA: false, + }, + }, + func(opts ...X509Opt) (*KeyPair, error) { + return CreateCert(caKeyPair, opts...) + }, + func(kp *KeyPair, dnsName string, at time.Time) (bool, error) { + return ValidateCert(caCerts, kp, dnsName, at) + }, + ) +} + +func TestValidateCert(t *testing.T) { + rootCA := "test-root" + rootKeyPair, err := CreateCA( + WithCommonName(rootCA), + ) + if err != nil { + t.Fatalf("CA cert creation should succeed. Got error: %v", err) + } + + intermediateCA := "test-intermediate" + intermediateCAKeyPair, err := CreateCert( + rootKeyPair, + WithCommonName(intermediateCA), + WithDNSNames(intermediateCA), + WithKeyUsage(x509.KeyUsageCertSign), + WithIsCA(true), + ) + if err != nil { + t.Fatalf("Intermediate CA cert creation should succeed. Got error: %v", err) + } + + rootCerts, err := rootKeyPair.Certificates() + if err != nil { + t.Fatalf("Unable to get CA certificates: %v", err) + } + if len(rootCerts) != 1 { + t.Fatalf("Unexpected number of root certs: %d", len(rootCerts)) + } + rootCert := rootCerts[0] + if rootCert.Subject.CommonName != rootCA { + t.Fatalf("Unexpected root cert common name, got: %v, want: %v", rootCert.Subject.CommonName, rootCA) + } + if rootCert.Issuer.CommonName != rootCA { + t.Fatalf("Unexpected root cert issuer, got: %v, want: %v", rootCert.Issuer.CommonName, rootCA) + } + + intermediateCerts, err := intermediateCAKeyPair.Certificates() + if err != nil { + t.Fatalf("Unable to get intermediate CA certificates: %v", err) + } + if len(intermediateCerts) != 1 { + t.Fatalf("Unexpected number of intermediate certs: %d", len(intermediateCerts)) + } + intermediateCert := intermediateCerts[0] + if intermediateCert.Subject.CommonName != intermediateCA { + t.Fatalf("Unexpected intermediate cert common name, got: %v, want: %v", intermediateCert.Subject.CommonName, intermediateCA) + } + if intermediateCert.Issuer.CommonName != rootCA { + t.Fatalf("Unexpected intermediate cert issuer, got: %v, want: %v", intermediateCert.Issuer.CommonName, rootCA) + } + + tests := []struct { + name string + createCertKeyPairFn func() *KeyPair + dnsName string + at time.Time + validateCertFn func(keyPair *KeyPair, dnsName string, at time.Time) (bool, error) + wantValid bool + wantErr bool + }{ + { + name: "CA invalid lifetime", + createCertKeyPairFn: func() *KeyPair { + return rootKeyPair + }, + dnsName: rootCA, + at: time.Now().Add(10 * 365 * 24 * time.Hour), // 10 years in the future + validateCertFn: ValidateCA, + wantValid: false, + wantErr: true, + }, + { + name: "CA invalid DNS name", + createCertKeyPairFn: func() *KeyPair { + return rootKeyPair + }, + dnsName: "foo", + at: time.Now(), + validateCertFn: ValidateCA, + wantValid: false, + wantErr: true, + }, + { + name: "CA valid root", + createCertKeyPairFn: func() *KeyPair { + return rootKeyPair + }, + dnsName: rootCA, + at: time.Now(), + validateCertFn: ValidateCA, + wantValid: true, + wantErr: false, + }, + { + name: "CA valid intermediate", + createCertKeyPairFn: func() *KeyPair { + return intermediateCAKeyPair + }, + dnsName: intermediateCA, + at: time.Now(), + validateCertFn: ValidateCA, + wantValid: true, + wantErr: false, + }, + { + name: "Cert issued by root valid", + createCertKeyPairFn: func() *KeyPair { + return mustCreateCert( + t, + rootKeyPair, + WithCommonName("issued-by-root"), + WithDNSNames("issued-by-root"), + ) + }, + dnsName: "issued-by-root", + at: time.Now(), + validateCertFn: func(keyPair *KeyPair, dnsName string, at time.Time) (bool, error) { + return ValidateCert( + []*x509.Certificate{ + rootCert, + }, + keyPair, + dnsName, + at, + ) + }, + wantValid: true, + wantErr: false, + }, + { + name: "Cert issued by root invalid trust chain", + createCertKeyPairFn: func() *KeyPair { + return mustCreateCert( + t, + rootKeyPair, + WithCommonName("issued-by-root"), + WithDNSNames("issued-by-root"), + ) + }, + dnsName: "issued-by-root", + at: time.Now(), + validateCertFn: func(keyPair *KeyPair, dnsName string, at time.Time) (bool, error) { + return ValidateCert( + []*x509.Certificate{ + intermediateCert, + }, + keyPair, + dnsName, + at, + ) + }, + wantValid: false, + wantErr: true, + }, + { + name: "Cert issued by root invalid lifetime", + createCertKeyPairFn: func() *KeyPair { + return mustCreateCert( + t, + rootKeyPair, + WithCommonName("issued-by-root"), + WithDNSNames("issued-by-root"), + ) + }, + dnsName: "issued-by-root", + at: time.Now().Add(10 * 365 * 24 * time.Hour), // 10 years in the future + validateCertFn: func(keyPair *KeyPair, dnsName string, at time.Time) (bool, error) { + return ValidateCert( + []*x509.Certificate{ + rootCert, + }, + keyPair, + dnsName, + at, + ) + }, + wantValid: false, + wantErr: true, + }, + { + name: "Cert issued by root invalid DNS name", + createCertKeyPairFn: func() *KeyPair { + return mustCreateCert( + t, + rootKeyPair, + WithCommonName("issued-by-root"), + WithDNSNames("issued-by-root"), + ) + }, + dnsName: "foo", + at: time.Now(), + validateCertFn: func(keyPair *KeyPair, dnsName string, at time.Time) (bool, error) { + return ValidateCert( + []*x509.Certificate{ + rootCert, + }, + keyPair, + dnsName, + at, + ) + }, + wantValid: false, + wantErr: true, + }, + { + name: "Cert issued by trusted intermediate valid", + createCertKeyPairFn: func() *KeyPair { + return mustCreateCert( + t, + intermediateCAKeyPair, + WithCommonName("issued-by-intermediate"), + WithDNSNames("issued-by-intermediate"), + ) + }, + dnsName: "issued-by-intermediate", + at: time.Now(), + validateCertFn: func(keyPair *KeyPair, dnsName string, at time.Time) (bool, error) { + return ValidateCert( + []*x509.Certificate{ + intermediateCert, + }, + keyPair, + dnsName, + at, + ) + }, + wantValid: true, + wantErr: false, + }, + { + name: "Cert issued by untrusted intermediate valid", + createCertKeyPairFn: func() *KeyPair { + return mustCreateCert( + t, + intermediateCAKeyPair, + WithCommonName("issued-by-intermediate"), + WithDNSNames("issued-by-intermediate"), + ) + }, + dnsName: "issued-by-intermediate", + at: time.Now(), + validateCertFn: func(keyPair *KeyPair, dnsName string, at time.Time) (bool, error) { + return ValidateCert( + []*x509.Certificate{ + rootCert, + }, + keyPair, + dnsName, + at, + WithIntermediateCAs(intermediateCert), + ) + }, + wantValid: true, + wantErr: false, + }, + { + name: "Cert issued by untrusted intermediate invalid trust chain", + createCertKeyPairFn: func() *KeyPair { + return mustCreateCert( + t, + intermediateCAKeyPair, + WithCommonName("issued-by-intermediate"), + WithDNSNames("issued-by-intermediate"), + ) + }, + dnsName: "issued-by-intermediate", + at: time.Now(), + validateCertFn: func(keyPair *KeyPair, dnsName string, at time.Time) (bool, error) { + return ValidateCert( + []*x509.Certificate{ + rootCert, + }, + keyPair, + dnsName, + at, + ) + }, + wantValid: false, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + valid, err := tt.validateCertFn(tt.createCertKeyPairFn(), tt.dnsName, tt.at) + if tt.wantErr && err == nil { + t.Fatalf("Expecting error to be non nil for test '%s'", tt.name) + } + if !tt.wantErr && err != nil { + t.Fatalf("Expecting error to be nil for test '%s'. Got: %v", tt.name, err) + } + if valid != tt.wantValid { + t.Fatalf("Unexpected validation result for test '%s'. Got: %v, want: %v", tt.name, valid, tt.wantValid) + } + }) + } +} + +func TestParseCertificates(t *testing.T) { + tests := []struct { + name string + certBytes []byte + wantErr bool + }{ + { + name: "Invalid cert", + certBytes: []byte("foo"), + wantErr: true, + }, + { + name: "No block cert", + certBytes: []byte(` +MIID3DCCAsSgAwIBAgIBATANBgkqhkiG9w0BAQsFADA2MRkwFwYDVQQKExBtYXJp +YWRiLW9wZXJhdG9yMRkwFwYDVQQDExBtYXJpYWRiLW9wZXJhdG9yMB4XDTIzMTEw +NTEwMzAxNVoXDTIzMTEwNTEyMzAxNVowLzEtMCsGA1UEAxMkbWFyaWFkYi1vcGVy +YXRvci13ZWJob29rLmRlZmF1bHQuc3ZjMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8A +MIIBCgKCAQEA1l202NMTln0/ngg4JXUJLJXvhSjjHimO22c47tHhWvnzhtnKCrH8 +cWBnnxO11os5PcNIUYTxn04mZPRs+p1YkE9DMlp9Lgy/38304rr4kjllVspvl9Md +relqbcDy520rgF/YObfMZvzeseH2F5UK386IXb1KYSmp8dn7RU2HvUf17Z/z1Scd +vOS4xXPNjuAi28REA72vPbFwLbt+mQxBQ/Aal6BNH5RhNIOZ9m8fVsWn/e/4hZTa +2Ib/pp/3j2D1UlJqBiAh4cBeI0QYbj/hN5+OpVUJA3+OsGzFOBhs7KfqMAP3KDTt +7sTrPV03QKKqhDjh3LIzdZyEWHPMJesMawIDAQABo4H7MIH4MA4GA1UdDwEB/wQE +AwIFoDATBgNVHSUEDDAKBggrBgEFBQcDATAMBgNVHRMBAf8EAjAAMB8GA1UdIwQY +MBaAFCJFv64s92+rdv6JGeVbQLHBxXyUMIGhBgNVHREEgZkwgZaCMm1hcmlhZGIt +b3BlcmF0b3Itd2ViaG9vay5kZWZhdWx0LnN2Yy5jbHVzdGVyLmxvY2FsgiRtYXJp +YWRiLW9wZXJhdG9yLXdlYmhvb2suZGVmYXVsdC5zdmOCIG1hcmlhZGItb3BlcmF0 +b3Itd2ViaG9vay5kZWZhdWx0ghhtYXJpYWRiLW9wZXJhdG9yLXdlYmhvb2swDQYJ +KoZIhvcNAQELBQADggEBABVoQWFqoB/wcdep9LlmWLqyVLy4Xx5mb0EhikvUKtE3 +5ChDjiiQQEYdrXsBzxLsgntIh9XFx94eX2QtjOvDUCJc3z0LLg+5c5GhWANzvB7A +G1ZUYSKs5sgS0o5oBaPt9opZqnA8WGgwZ1WR1pxRBpLmu/019vDABAUX5tV3iqVp +qYxy6XmWp5Gc7c2NqlQ9N5xsMXMSfLiUSC8O+2sJGU92GtVSp7Vt4nGg1Qh5ZyHJ +fK6S3LzTZ/HVm8nXY1e0ZnrG7SZbcJkkZgSPOjsZ9KSikdG4I9+S99FTe8X1Qzn8 +0ER77C84IUS9PEuvnSlWXopwKg5aAdHS5nHp7UFiNt4= +`), + wantErr: true, + }, + { + name: "Valid cert", + certBytes: []byte(`-----BEGIN CERTIFICATE----- +MIICXzCCAgagAwIBAgIRAIBgotjwHCDFrV2H9FWQrYIwCgYIKoZIzj0EAwIwNjEZ +MBcGA1UEChMQbWFyaWFkYi1vcGVyYXRvcjEZMBcGA1UEAxMQbWFyaWFkYi1vcGVy +YXRvcjAeFw0yNDEyMTkxNjI2NTVaFw0yNTEyMTkyMzI2NTVaMC8xLTArBgNVBAMT +JG1hcmlhZGItb3BlcmF0b3Itd2ViaG9vay5kZWZhdWx0LnN2YzBZMBMGByqGSM49 +AgEGCCqGSM49AwEHA0IABIk1YZK4gZLLlluVtzL/S/dfJtQRAmh1Je2Vfz89qvOM +GPWhG8Xtjyd3Ntg7RBc4PXpjbq7lUufxy/oWp88+KPqjgfswgfgwDgYDVR0PAQH/ +BAQDAgWgMBMGA1UdJQQMMAoGCCsGAQUFBwMBMAwGA1UdEwEB/wQCMAAwHwYDVR0j +BBgwFoAUXZzsgcecviPCXlVdrBw/tUEC2uYwgaEGA1UdEQSBmTCBloIybWFyaWFk +Yi1vcGVyYXRvci13ZWJob29rLmRlZmF1bHQuc3ZjLmNsdXN0ZXIubG9jYWyCJG1h +cmlhZGItb3BlcmF0b3Itd2ViaG9vay5kZWZhdWx0LnN2Y4IgbWFyaWFkYi1vcGVy +YXRvci13ZWJob29rLmRlZmF1bHSCGG1hcmlhZGItb3BlcmF0b3Itd2ViaG9vazAK +BggqhkjOPQQDAgNHADBEAiBSWY1rVufSE+3i0w553uJGJCC4Fpa6cvRPEti8X3Kp +1AIgG0qN5IT9EsRZaY4J2vBYsbN5LL+qRI5N0XGYqVWXuD8= +-----END CERTIFICATE----- +`), + wantErr: false, + }, + { + name: "Valid cert bundle", + certBytes: []byte(`-----BEGIN CERTIFICATE----- +MIICFDCCAX2gAwIBAgIUAKik9DYK3ZWXZYqwYE310UeuqWowDQYJKoZIhvcNAQEL +BQAwHDEaMBgGA1UEAwwRbWFyaWFkYi1jbGllbnQtY2EwHhcNMjQxMTA4MTcxMTM0 +WhcNMjUxMTA4MTcxMTM0WjAcMRowGAYDVQQDDBFtYXJpYWRiLWNsaWVudC1jYTCB +nzANBgkqhkiG9w0BAQEFAAOBjQAwgYkCgYEAvMhNoeq4M/PXLbvkeeuegP3zWouG +u7a35kvXS0YPMhlQV08GcyDyKkt6cG4GrZ3bJUhtcqmzT8oqYKxb9T6W9HU5+gpr +BCScUWViCYX0pKhucEPHP/5xAJuGnnzg0BqR2Tzt95IDmg+tkFKGOnVn9Qx9RfXO +ZpEHL42pNSEU/9kCAwEAAaNTMFEwHQYDVR0OBBYEFCUdplOwmy91F9mlBbQ58UuN +ob4fMB8GA1UdIwQYMBaAFCUdplOwmy91F9mlBbQ58UuNob4fMA8GA1UdEwEB/wQF +MAMBAf8wDQYJKoZIhvcNAQELBQADgYEASsuxA5A5aVjl1QN/SrLGLIMOvcDnYdtW +HpZmElox1PR72AFV2H/Ig/9ixK+3DykMbDf6RiwMZBtgQVuHTRD8QoEk/gG5OEOP +VDiVGD+f28/5eme54pwI9FUuKxujP0pj4VPiCKR2igJcJnCIAeDTlNmcs7CiXtIn +WVQiuKIOhYk= +-----END CERTIFICATE----- +-----BEGIN CERTIFICATE----- +MIICFDCCAX2gAwIBAgIUCsM6MEeesw4qTYrp5laVrZhwopEwDQYJKoZIhvcNAQEL +BQAwHDEaMBgGA1UEAwwRbWFyaWFkYi1zZXJ2ZXItY2EwHhcNMjQxMTA4MTcxMTM0 +WhcNMjUxMTA4MTcxMTM0WjAcMRowGAYDVQQDDBFtYXJpYWRiLXNlcnZlci1jYTCB +nzANBgkqhkiG9w0BAQEFAAOBjQAwgYkCgYEAv8H2G9AKtM+tc0rR4GAm6CHYTffF +wLICdiUpcnLkqvMIU/YFsjBDFCbzUkmz7Fni176s1LH3tekBneRkFZ7hoyEwccbX +e3gBnnfGma7DzWvmRWMYf0dpnk4stOxZ44V/DJ2pSE7zI7zrH6w9dLRmJFcaQrQO +WWXkPnsQL3LArEECAwEAAaNTMFEwHQYDVR0OBBYEFN8WJNuBah6vZkrTjBESN+fc +yvLOMB8GA1UdIwQYMBaAFN8WJNuBah6vZkrTjBESN+fcyvLOMA8GA1UdEwEB/wQF +MAMBAf8wDQYJKoZIhvcNAQELBQADgYEAqymYNbFm/DX20eAkTBYyih6oAz5ETNJU +jDqaasPK77oFD2eEjSCI3jewj8xYaGfTgohB+YdkM9VWN+s5zsxBakTY19U7GeQJ +xj8tutwZ3pBj0lLiTnzYb6VnXpl12TiHImapwwAkZEpMZ3W3o0TjK2gyc6F9o2h/ +idE60fGmuV8= +-----END CERTIFICATE----- +`), + wantErr: false, + }, + { + name: "Invalid cert bundle", + certBytes: []byte(`-----BEGIN CERTIFICATE----- +MIICFDCCAX2gAwIBAgIUAKik9DYK3ZWXZYqwYE310UeuqWowDQYJKoZIhvcNAQEL +BQAwHDEaMBgGA1UEAwwRbWFyaWFkYi1jbGllbnQtY2EwHhcNMjQxMTA4MTcxMTM0 +WhcNMjUxMTA4MTcxMTM0WjAcMRowGAYDVQQDDBFtYXJpYWRiLWNsaWVudC1jYTCB +nzANBgkqhkiG9w0BAQEFAAOBjQAwgYkCgYEAvMhNoeq4M/PXLbvkeeuegP3zWouG +u7a35kvXS0YPMhlQV08GcyDyKkt6cG4GrZ3bJUhtcqmzT8oqYKxb9T6W9HU5+gpr +BCScUWViCYX0pKhucEPHP/5xAJuGnnzg0BqR2Tzt95IDmg+tkFKGOnVn9Qx9RfXO +ZpEHL42pNSEU/9kCAwEAAaNTMFEwHQYDVR0OBBYEFCUdplOwmy91F9mlBbQ58UuN +ob4fMB8GA1UdIwQYMBaAFCUdplOwmy91F9mlBbQ58UuNob4fMA8GA1UdEwEB/wQF +MAMBAf8wDQYJKoZIhvcNAQELBQADgYEASsuxA5A5aVjl1QN/SrLGLIMOvcDnYdtW +HpZmElox1PR72AFV2H/Ig/9ixK+3DykMbDf6RiwMZBtgQVuHTRD8QoEk/gG5OEOP +VDiVGD+f28/5eme54pwI9FUuKxujP0pj4VPiCKR2igJcJnCIAeDTlNmcs7CiXtIn +WVQiuKIOhYk= +-----END CERTIFICATE----- +-----BEGIN CERTIFICATE----- +MIICFDCCAX2gAwIBAgIUCsM6MEeesw4qTYrp5laVrZhwopEwDQYJKoZIhvcNAQEL +BQAwHDEaMBgGA1UEAwwRbWFyaWFkYi1zZXJ2ZXItY2EwHhcNMjQxMTA4MTcxMTM0 +WhcNMjUxMTA4MTcxMTM0WjAcMRowGAYDVQQDDBFtYXJpYWRiLXNlcnZlci1jYTCB +nzANBgkqhkiG9w0BAQEFAAOBjQAwgYkCgYEAv8H2G9AKtM+tc0rR4GAm6CHYTffF +wLICdiUpcnLkqvMIU/YFsjBDFCbzUkmz7Fni176s1LH3tekBneRkFZ7hoyEwccbX +e3gBnnfGma7DzWvmRWMYf0dpnk4stOxZ44V/DJ2pSE7zI7zrH6w9dLRmJFcaQrQO +WWXkPnsQL3LArEECAwEAAaNTMFEwHQYDVR0OBBYEFN8WJNuBah6vZkrTjBESN+fc +yvLOMB8GA1UdIwQYMBaAFN8WJNuBah6vZkrTjBESN+fcyvLOMA8GA1UdEwEB/wQF +MAMBAf8wDQYJKoZIhvcNAQELBQADgYEAqymYNbFm/DX20eAkTBYyih6oAz5ETNJU +jDqaasPK77oFD2eEjSCI3jewj8xYaGfTgohB+YdkM9VWN+s5zsxBakTY19U7GeQJ +xj8tutwZ3pBj0lLiTnzYb6VnXpl12TiHImapwwAkZEpMZ3W3o0TjK2gyc6F9o2h/ +idE60fGmuV8= +-----END CERTIFICATE----- +-----BEGIN CERTIFICATE----- +invalid +-----END CERTIFICATE----- +`), + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := ParseCertificates(tt.certBytes) + if tt.wantErr && err == nil { + t.Fatalf("Expecting error to be non nil when parsing '%s'", tt.name) + } + if !tt.wantErr && err != nil { + t.Fatalf("Expecting error to be nil when parsing '%s'. Got: %v", tt.name, err) + } + }) + } +} + +func TestValidateLifetime(t *testing.T) { + minLifetime := 1 * time.Hour + maxLifetime := 5 * 365 * 24 * time.Hour // 5 years + + tests := []struct { + name string + notBefore time.Time + notAfter time.Time + minDuration time.Duration + maxDuration time.Duration + wantErr bool + }{ + { + name: "Valid lifetime", + notBefore: time.Now(), + notAfter: time.Now().Add(2 * time.Hour), + minDuration: minLifetime, + maxDuration: maxLifetime, + wantErr: false, + }, + { + name: "NotBefore after NotAfter", + notBefore: time.Now().Add(2 * time.Hour), + notAfter: time.Now(), + minDuration: minLifetime, + maxDuration: maxLifetime, + wantErr: true, + }, + { + name: "Duration less than minimum", + notBefore: time.Now(), + notAfter: time.Now().Add(30 * time.Minute), + minDuration: minLifetime, + maxDuration: maxLifetime, + wantErr: true, + }, + { + name: "Duration exceeds maximum", + notBefore: time.Now(), + notAfter: time.Now().Add(6 * 365 * 24 * time.Hour), // 6 years + minDuration: minLifetime, + maxDuration: maxLifetime, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validateLifetime(tt.notBefore, tt.notAfter, tt.minDuration, tt.maxDuration) + if tt.wantErr && err == nil { + t.Fatalf("Expecting error to be non nil for test '%s'", tt.name) + } + if !tt.wantErr && err != nil { + t.Fatalf("Expecting error to be nil for test '%s'. Got: %v", tt.name, err) + } + }) + } +} + +type testCaseCreateCert struct { + name string + x509Opts []X509Opt + wantErr bool + wantCommonName string + wantIssuer string + wantDNSNames []string + wantKeyUsage x509.KeyUsage + wantExtKeyUsage []x509.ExtKeyUsage + wantIsCA bool +} + +func testCreateCert( + t *testing.T, + tests []testCaseCreateCert, + createCertFn func(...X509Opt) (*KeyPair, error), + validateCertFn func(*KeyPair, string, time.Time) (bool, error), +) { + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + keyPair, err := createCertFn(tt.x509Opts...) + if tt.wantErr && err == nil { + t.Fatalf("Expecting error to be non nil when creating cert '%s'", tt.name) + } + if !tt.wantErr && err != nil { + t.Fatalf("Expecting error to be nil when creating cert '%s'. Got: %v", tt.name, err) + } + if tt.wantErr { + return + } + + cert, err := keyPair.LeafCertificate() // we are only creating certificates with a single leaf cert, therefore only one PEM block + if err != nil { + t.Fatalf("error getting leaf certificate: %v", err) + } + commonName := cert.Subject.CommonName + notBefore := cert.NotBefore + + if commonName != tt.wantCommonName { + t.Fatalf("unexpected common name, got: %v, want: %v", commonName, tt.wantCommonName) + } + if cert.Issuer.CommonName != tt.wantIssuer { + t.Fatalf("unexpected issuer, got: %v, want: %v", cert.Issuer.CommonName, tt.wantIssuer) + } + if !reflect.DeepEqual(cert.DNSNames, tt.wantDNSNames) { + t.Fatalf("unexpected DNS names, got: %v, want: %v", cert.DNSNames, tt.wantDNSNames) + } + if !reflect.DeepEqual(cert.KeyUsage, tt.wantKeyUsage) { + t.Fatalf("unexpected key usage, got: %v, want: %v", cert.KeyUsage, tt.wantKeyUsage) + } + if !reflect.DeepEqual(cert.ExtKeyUsage, tt.wantExtKeyUsage) { + t.Fatalf("unexpected extended key usage, got: %v, want: %v", cert.ExtKeyUsage, tt.wantExtKeyUsage) + } + if cert.IsCA != tt.wantIsCA { + t.Fatalf("unexpected IsCA, got: %v, want: %v", cert.IsCA, tt.wantIsCA) + } + + valid, err := validateCertFn(keyPair, commonName, notBefore.Add(-1*time.Hour)) + if err == nil { + t.Fatalf("Cert validation should return an error. Got nil") + } + if valid { + t.Fatal("Expected cert to be invalid") + } + + valid, err = validateCertFn(keyPair, "foo", time.Now()) + if err == nil { + t.Fatalf("Cert validation should return an error. Got nil") + } + if valid { + t.Fatal("Expected cert to be invalid") + } + + keyPair, err = createCertFn(tt.x509Opts...) + if err != nil { + t.Fatalf("Certificate renewal should succeed. Got error: %v", err) + } + + valid, err = validateCertFn(keyPair, commonName, time.Now()) + if err != nil { + t.Fatalf("Cert validation should succeed after renewal. Got error: %v", err) + } + if !valid { + t.Fatal("Expected cert to be valid") + } + }) + } +} + +func mustCreateCert(t *testing.T, caKeyPair *KeyPair, opts ...X509Opt) *KeyPair { + keyPair, err := CreateCert(caKeyPair, opts...) + if err != nil { + t.Fatalf("unexpected error creating cert: %v", err) + } + return keyPair +} diff --git a/pkg/refresolver/refresolver.go b/pkg/refresolver/refresolver.go index 17851bf506..3e723cfeb7 100644 --- a/pkg/refresolver/refresolver.go +++ b/pkg/refresolver/refresolver.go @@ -116,7 +116,7 @@ func (r *RefResolver) SecretKeyRef(ctx context.Context, selector mariadbv1alpha1 } var secret corev1.Secret if err := r.client.Get(ctx, key, &secret); err != nil { - return "", fmt.Errorf("error getting Secret: %v", err) + return "", err } data, ok := secret.Data[selector.Key] @@ -134,7 +134,7 @@ func (r *RefResolver) ConfigMapKeyRef(ctx context.Context, selector *mariadbv1al } var configMap corev1.ConfigMap if err := r.client.Get(ctx, key, &configMap); err != nil { - return "", fmt.Errorf("error getting ConfigMap: %v", err) + return "", err } data, ok := configMap.Data[selector.Key] diff --git a/pkg/sql/sql.go b/pkg/sql/sql.go index bce3377f10..2fbf749197 100644 --- a/pkg/sql/sql.go +++ b/pkg/sql/sql.go @@ -3,9 +3,12 @@ package sql import ( "bytes" "context" + "crypto/tls" + "crypto/x509" "database/sql" "errors" "fmt" + "os" "strings" "text/template" "time" @@ -13,8 +16,10 @@ import ( "github.com/go-sql-driver/mysql" mariadbv1alpha1 "github.com/mariadb-operator/mariadb-operator/api/v1alpha1" "github.com/mariadb-operator/mariadb-operator/pkg/environment" + "github.com/mariadb-operator/mariadb-operator/pkg/pki" "github.com/mariadb-operator/mariadb-operator/pkg/refresolver" "github.com/mariadb-operator/mariadb-operator/pkg/statefulset" + "k8s.io/apimachinery/pkg/types" ) var ( @@ -27,8 +32,18 @@ type Opts struct { Host string Port int32 Database string - Params map[string]string - Timeout *time.Duration + + MariadbName string + MaxscaleName string + Namespace string + ClientName string + + TLSCACert []byte + TLSClientCert []byte + TLSClientPrivateKey []byte + + Params map[string]string + Timeout *time.Duration } type Opt func(*Opts) @@ -63,6 +78,30 @@ func WithDatabase(database string) Opt { } } +func WithMariadbTLS(name, namespace string, tlsCaCert []byte) Opt { + return func(o *Opts) { + o.MariadbName = name + o.Namespace = namespace + o.TLSCACert = tlsCaCert + } +} + +func WithMaxscaleTLS(name, namespace string, tlsCaCert []byte) Opt { + return func(o *Opts) { + o.MaxscaleName = name + o.Namespace = namespace + o.TLSCACert = tlsCaCert + } +} + +func WithTLSClientCert(clientName string, cert, privateKey []byte) Opt { + return func(o *Opts) { + o.ClientName = clientName + o.TLSClientCert = cert + o.TLSClientPrivateKey = privateKey + } +} + func WithParams(params map[string]string) Opt { return func(o *Opts) { o.Params = params @@ -117,6 +156,43 @@ func NewClientWithMariaDB(ctx context.Context, mariadb *mariadbv1alpha1.MariaDB, }()), WithPort(mariadb.Spec.Port), } + + if mariadb.IsTLSEnabled() { + caCert, err := refResolver.SecretKeyRef(ctx, mariadb.TLSCABundleSecretKeyRef(), mariadb.Namespace) + if err != nil { + return nil, fmt.Errorf("error getting CA certificate: %v", err) + } + opts = append(opts, WithMariadbTLS(mariadb.Name, mariadb.Namespace, []byte(caCert))) + + clientSecretKey := types.NamespacedName{ + Name: mariadb.TLSClientCertSecretKey().Name, + Namespace: mariadb.Namespace, + } + clientCertSelector := mariadbv1alpha1.SecretKeySelector{ + LocalObjectReference: mariadbv1alpha1.LocalObjectReference{ + Name: clientSecretKey.Name, + }, + Key: pki.TLSCertKey, + } + clientCert, err := refResolver.SecretKeyRef(ctx, clientCertSelector, clientSecretKey.Namespace) + if err != nil { + return nil, fmt.Errorf("error getting client certificate: %v", err) + } + + clientPrivateKeySelector := mariadbv1alpha1.SecretKeySelector{ + LocalObjectReference: mariadbv1alpha1.LocalObjectReference{ + Name: clientSecretKey.Name, + }, + Key: pki.TLSKeyKey, + } + clientPrivateKey, err := refResolver.SecretKeyRef(ctx, clientPrivateKeySelector, clientSecretKey.Namespace) + if err != nil { + return nil, fmt.Errorf("error getting client private key: %v", err) + } + + opts = append(opts, WithTLSClientCert(clientCertSelector.Name, []byte(clientCert), []byte(clientPrivateKey))) + } + opts = append(opts, clientOpts...) return NewClient(opts...) } @@ -147,6 +223,19 @@ func NewLocalClientWithPodEnv(ctx context.Context, env *environment.PodEnvironme WitHost("localhost"), WithPort(port), } + + isTLSEnabled, err := env.IsTLSEnabled() + if err != nil { + return nil, fmt.Errorf("error checking whether TLS is enabled in environment: %v", err) + } + if isTLSEnabled { + caCert, err := os.ReadFile(env.TLSCACertPath) + if err != nil { + return nil, fmt.Errorf("error reading CA certificate: %v", err) + } + opts = append(opts, WithMariadbTLS(env.MariadbName, env.PodNamespace, caCert)) + } + opts = append(opts, clientOpts...) return NewClient(opts...) } @@ -174,10 +263,60 @@ func BuildDSN(opts Opts) (string, error) { if opts.Params != nil { config.Params = opts.Params } - + if (opts.MariadbName != "" || opts.MaxscaleName != "") && opts.Namespace != "" && opts.TLSCACert != nil { + configName, err := configureTLS(opts) + if err != nil { + return "", fmt.Errorf("error configuring TLS: %v", err) + } + config.TLSConfig = configName + } return config.FormatDSN(), nil } +func configureTLS(opts Opts) (string, error) { + configName, err := configTLSName(opts) + if err != nil { + return "", fmt.Errorf("error getting TLS config name: %v", err) + } + var tlsCfg tls.Config + + caBundle := x509.NewCertPool() + if ok := caBundle.AppendCertsFromPEM(opts.TLSCACert); ok { + tlsCfg.RootCAs = caBundle + } else { + return "", errors.New("failed parse pem-encoded CA certificates") + } + + if opts.TLSClientCert != nil && opts.TLSClientPrivateKey != nil { + keyPair, err := tls.X509KeyPair(opts.TLSClientCert, opts.TLSClientPrivateKey) + if err != nil { + return "", fmt.Errorf("error parsing client keypair: %v", err) + } + tlsCfg.Certificates = []tls.Certificate{keyPair} + } + + if err := mysql.RegisterTLSConfig(configName, &tlsCfg); err != nil { + return "", fmt.Errorf("error registering TLS config \"%s\": %v", configName, err) + } + return configName, nil +} + +func configTLSName(opts Opts) (string, error) { + var configName string + if opts.MariadbName != "" { + configName = fmt.Sprintf("mariadb-%s-%s", opts.MariadbName, opts.Namespace) + } else if opts.MaxscaleName != "" { + configName = fmt.Sprintf("maxscale-%s-%s", opts.MaxscaleName, opts.Namespace) + } else { + return "", errors.New("unable to create config name: either MariaDB or MaxScale names must be set") + } + + if opts.ClientName != "" { + configName += fmt.Sprintf("-client-%s", opts.ClientName) + } + return configName, nil +} + func Connect(dsn string) (*sql.DB, error) { db, err := sql.Open("mysql", dsn) if err != nil { @@ -211,6 +350,7 @@ type CreateUserOpts struct { IdentifiedByPassword string IdentifiedVia string IdentifiedViaUsing string + Require *mariadbv1alpha1.TLSRequirements MaxUserConnections int32 } @@ -240,6 +380,12 @@ func WithIdentifiedViaUsing(viaUsing string) CreateUserOpt { } } +func WithTLSRequirements(require *mariadbv1alpha1.TLSRequirements) CreateUserOpt { + return func(cuo *CreateUserOpts) { + cuo.Require = require + } +} + func WithMaxUserConnections(maxConns int32) CreateUserOpt { return func(cuo *CreateUserOpts) { cuo.MaxUserConnections = maxConns @@ -263,6 +409,15 @@ func (c *Client) CreateUser(ctx context.Context, accountName string, createUserO } else if opts.IdentifiedBy != "" { query += fmt.Sprintf("IDENTIFIED BY '%s' ", opts.IdentifiedBy) } + + if require := opts.Require; require != nil { + requireSubQuery, err := requireQuery(require) + if err != nil { + return fmt.Errorf("error processing require subquery: %v", err) + } + query += fmt.Sprintf("%s ", requireSubQuery) + } + query += fmt.Sprintf("WITH MAX_USER_CONNECTIONS %d ", opts.MaxUserConnections) if opts.IdentifiedBy == "" && opts.IdentifiedByPassword == "" && opts.IdentifiedVia == "" { query += "ACCOUNT LOCK PASSWORD EXPIRE " @@ -296,6 +451,15 @@ func (c *Client) AlterUser(ctx context.Context, accountName string, createUserOp } else { query += fmt.Sprintf("IDENTIFIED BY '%s' ", opts.IdentifiedBy) } + + if require := opts.Require; require != nil { + requireSubQuery, err := requireQuery(require) + if err != nil { + return fmt.Errorf("error processing require subquery: %v", err) + } + query += fmt.Sprintf("%s ", requireSubQuery) + } + query += fmt.Sprintf("WITH MAX_USER_CONNECTIONS %d ", opts.MaxUserConnections) query += ";" @@ -499,23 +663,110 @@ type ChangeMasterOpts struct { Password string Gtid string Retries int + + SSLEnabled bool + SSLCertPath string + SSLKeyPath string + SSLCAPath string +} + +type ChangeMasterOpt func(*ChangeMasterOpts) + +func WithChangeMasterConnection(connection string) ChangeMasterOpt { + return func(cmo *ChangeMasterOpts) { + cmo.Connection = connection + } +} + +func WithChangeMasterHost(host string) ChangeMasterOpt { + return func(cmo *ChangeMasterOpts) { + cmo.Host = host + } +} + +func WithChangeMasterPort(port int32) ChangeMasterOpt { + return func(cmo *ChangeMasterOpts) { + cmo.Port = port + } +} + +func WithChangeMasterCredentials(user, password string) ChangeMasterOpt { + return func(cmo *ChangeMasterOpts) { + cmo.User = user + cmo.Password = password + } +} + +func WithChangeMasterGtid(gtid string) ChangeMasterOpt { + return func(cmo *ChangeMasterOpts) { + cmo.Gtid = gtid + } +} + +func WithChangeMasterRetries(retries int) ChangeMasterOpt { + return func(cmo *ChangeMasterOpts) { + cmo.Retries = retries + } +} + +func WithChangeMasterSSL(certPath, keyPath, caPath string) ChangeMasterOpt { + return func(cmo *ChangeMasterOpts) { + cmo.SSLEnabled = true + cmo.SSLCertPath = certPath + cmo.SSLKeyPath = keyPath + cmo.SSLCAPath = caPath + } +} + +func (c *Client) ChangeMaster(ctx context.Context, changeMasterOpts ...ChangeMasterOpt) error { + query, err := buildChangeMasterQuery(changeMasterOpts...) + if err != nil { + return fmt.Errorf("error building CHANGE MASTER query: %v", err) + } + return c.Exec(ctx, query) } -func (c *Client) ChangeMaster(ctx context.Context, opts *ChangeMasterOpts) error { +func buildChangeMasterQuery(changeMasterOpts ...ChangeMasterOpt) (string, error) { + opts := ChangeMasterOpts{ + Connection: "mariadb-operator", + Port: 3306, + Gtid: "CurrentPos", + Retries: 10, + } + for _, setOpt := range changeMasterOpts { + setOpt(&opts) + } + if opts.Host == "" { + return "", errors.New("host must be provided") + } + if opts.User == "" || opts.Password == "" { + return "", errors.New("credentials must be provided") + } + if opts.SSLEnabled && (opts.SSLCertPath == "" || opts.SSLKeyPath == "" || opts.SSLCAPath == "") { + return "", errors.New("all SSL paths must be provided when SSL is enabled") + } + tpl := createTpl("change-master.sql", `CHANGE MASTER '{{ .Connection }}' TO MASTER_HOST='{{ .Host }}', MASTER_PORT={{ .Port }}, MASTER_USER='{{ .User }}', MASTER_PASSWORD='{{ .Password }}', MASTER_USE_GTID={{ .Gtid }}, -MASTER_CONNECT_RETRY={{ .Retries }}; +MASTER_CONNECT_RETRY={{ .Retries }}{{ if .SSLEnabled }},{{ else }};{{ end }} +{{- if .SSLEnabled }} +MASTER_SSL=1, +MASTER_SSL_CERT='{{ .SSLCertPath }}', +MASTER_SSL_KEY='{{ .SSLKeyPath }}', +MASTER_SSL_CA='{{ .SSLCAPath }}', +MASTER_SSL_VERIFY_SERVER_CERT=1; +{{- end }} `) buf := new(bytes.Buffer) err := tpl.Execute(buf, opts) if err != nil { - return fmt.Errorf("error generating change master query: %v", err) + return "", fmt.Errorf("error rendering CHANGE MASTER template: %v", err) } - return c.Exec(ctx, buf.String()) + return buf.String(), nil } func (c *Client) ResetSlavePos(ctx context.Context) error { @@ -572,6 +823,35 @@ func (c *Client) DropMaxScaleConfig(ctx context.Context) error { return c.Exec(ctx, "DROP TABLE maxscale_config") } +func requireQuery(require *mariadbv1alpha1.TLSRequirements) (string, error) { + if require == nil { + return "", errors.New("TLS requirements must be set") + } + if err := require.Validate(); err != nil { + return "", fmt.Errorf("invalid TLS requirements: %v", err) + } + var tlsOptions []string + + if require.SSL != nil && *require.SSL { + tlsOptions = append(tlsOptions, "SSL") + } + if require.X509 != nil && *require.X509 { + tlsOptions = append(tlsOptions, "X509") + } + if require.Issuer != nil && *require.Issuer != "" { + tlsOptions = append(tlsOptions, fmt.Sprintf("ISSUER '%s'", *require.Issuer)) + } + if require.Subject != nil && *require.Subject != "" { + tlsOptions = append(tlsOptions, fmt.Sprintf("SUBJECT '%s'", *require.Subject)) + } + + if len(tlsOptions) == 0 { + return "", errors.New("no valid TLS requirements specified") + } + + return fmt.Sprintf("REQUIRE %s", strings.Join(tlsOptions, " AND ")), nil +} + func createTpl(name, t string) *template.Template { return template.Must(template.New(name).Parse(t)) } diff --git a/pkg/sql/sql_test.go b/pkg/sql/sql_test.go new file mode 100644 index 0000000000..5354731fdc --- /dev/null +++ b/pkg/sql/sql_test.go @@ -0,0 +1,200 @@ +package sql + +import ( + "testing" + + "github.com/google/go-cmp/cmp" + mariadbv1alpha1 "github.com/mariadb-operator/mariadb-operator/api/v1alpha1" + "k8s.io/utils/ptr" +) + +func TestBuildChangeMasterQuery(t *testing.T) { + tests := []struct { + name string + options []ChangeMasterOpt + wantQuery string + wantErr bool + }{ + { + name: "missing host", + options: []ChangeMasterOpt{ + WithChangeMasterPort(3306), + WithChangeMasterCredentials("repl", "password"), + }, + wantQuery: "", + wantErr: true, + }, + { + name: "missing credentials", + options: []ChangeMasterOpt{ + WithChangeMasterHost("127.0.0.1"), + WithChangeMasterPort(3306), + }, + wantQuery: "", + wantErr: true, + }, + { + name: "valid without SSL", + options: []ChangeMasterOpt{ + WithChangeMasterHost("127.0.0.1"), + WithChangeMasterPort(3306), + WithChangeMasterCredentials("repl", "password"), + WithChangeMasterGtid("CurrentPos"), + }, + wantQuery: `CHANGE MASTER 'mariadb-operator' TO +MASTER_HOST='127.0.0.1', +MASTER_PORT=3306, +MASTER_USER='repl', +MASTER_PASSWORD='password', +MASTER_USE_GTID=CurrentPos, +MASTER_CONNECT_RETRY=10; +`, + wantErr: false, + }, + { + name: "missing SSL paths", + options: []ChangeMasterOpt{ + WithChangeMasterHost("127.0.0.1"), + WithChangeMasterPort(3306), + WithChangeMasterCredentials("repl", "password"), + WithChangeMasterSSL("", "", ""), + }, + wantQuery: "", + wantErr: true, + }, + { + name: "valid with SSL", + options: []ChangeMasterOpt{ + WithChangeMasterHost("127.0.0.1"), + WithChangeMasterPort(3306), + WithChangeMasterCredentials("repl", "password"), + WithChangeMasterGtid("CurrentPos"), + WithChangeMasterSSL("/etc/pki/client.crt", "/etc/pki/client.key", "/etc/pki/ca.crt"), + }, + wantQuery: `CHANGE MASTER 'mariadb-operator' TO +MASTER_HOST='127.0.0.1', +MASTER_PORT=3306, +MASTER_USER='repl', +MASTER_PASSWORD='password', +MASTER_USE_GTID=CurrentPos, +MASTER_CONNECT_RETRY=10, +MASTER_SSL=1, +MASTER_SSL_CERT='/etc/pki/client.crt', +MASTER_SSL_KEY='/etc/pki/client.key', +MASTER_SSL_CA='/etc/pki/ca.crt', +MASTER_SSL_VERIFY_SERVER_CERT=1; +`, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + query, err := buildChangeMasterQuery(tt.options...) + + if tt.wantErr { + if err == nil { + t.Fatalf("expected error but got nil") + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if diff := cmp.Diff(query, tt.wantQuery); diff != "" { + t.Errorf("unexpected query (-want +got):\n%s", diff) + } + }) + } +} + +func TestRequireQuery(t *testing.T) { + tests := []struct { + name string + require *mariadbv1alpha1.TLSRequirements + wantQuery string + wantErr bool + }{ + { + name: "nil", + require: nil, + wantQuery: "", + wantErr: true, + }, + { + name: "empty", + require: &mariadbv1alpha1.TLSRequirements{}, + wantQuery: "", + wantErr: true, + }, + { + name: "SSL", + require: &mariadbv1alpha1.TLSRequirements{ + SSL: ptr.To(true), + }, + wantQuery: "REQUIRE SSL", + wantErr: false, + }, + { + name: "X509", + require: &mariadbv1alpha1.TLSRequirements{ + X509: ptr.To(true), + }, + wantQuery: "REQUIRE X509", + wantErr: false, + }, + { + name: "Issuer", + require: &mariadbv1alpha1.TLSRequirements{ + Issuer: ptr.To("/CN=mariadb-galera-ca"), + }, + wantQuery: "REQUIRE ISSUER '/CN=mariadb-galera-ca'", + wantErr: false, + }, + { + name: "Subject", + require: &mariadbv1alpha1.TLSRequirements{ + Subject: ptr.To("/CN=mariadb-galera-client"), + }, + wantQuery: "REQUIRE SUBJECT '/CN=mariadb-galera-client'", + wantErr: false, + }, + { + name: "Issuer and Subject", + require: &mariadbv1alpha1.TLSRequirements{ + Issuer: ptr.To("/CN=mariadb-galera-ca"), + Subject: ptr.To("/CN=mariadb-galera-client"), + }, + wantQuery: "REQUIRE ISSUER '/CN=mariadb-galera-ca' AND SUBJECT '/CN=mariadb-galera-client'", + wantErr: false, + }, + { + name: "Multiple", + require: &mariadbv1alpha1.TLSRequirements{ + SSL: ptr.To(true), + X509: ptr.To(true), + Issuer: ptr.To("/CN=mariadb-galera-ca"), + Subject: ptr.To("/CN=mariadb-galera-client"), + }, + wantQuery: "", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotQuery, err := requireQuery(tt.require) + + if tt.wantErr && err == nil { + t.Error("expect error to have occurred, got nil") + } + if !tt.wantErr && err != nil { + t.Errorf("expect error to not have occurred, got: %v", err) + } + if diff := cmp.Diff(tt.wantQuery, gotQuery); diff != "" { + t.Errorf("unexpected bundle content (-want +got):\n%s", diff) + } + }) + } +} diff --git a/pkg/statefulset/statefulset.go b/pkg/statefulset/statefulset.go index b87ae445e2..07ea12983f 100644 --- a/pkg/statefulset/statefulset.go +++ b/pkg/statefulset/statefulset.go @@ -14,6 +14,28 @@ import ( "k8s.io/utils/ptr" ) +func HeadlessServiceNameVariants(meta metav1.ObjectMeta, pod, service string) []string { + clusterName := os.Getenv("CLUSTER_NAME") + if clusterName == "" { + clusterName = "cluster.local" + } + return []string{ + fmt.Sprintf("%s.%s.%s.svc.%s", pod, service, meta.Namespace, clusterName), + fmt.Sprintf("%s.%s.%s.svc", pod, service, meta.Namespace), + fmt.Sprintf("%s.%s.%s", pod, service, meta.Namespace), + fmt.Sprintf("%s.%s", pod, service), + } +} + +func ServiceNameVariants(meta metav1.ObjectMeta, service string) []string { + return []string{ + ServiceFQDNWithService(meta, service), + fmt.Sprintf("%s.%s.svc", service, meta.Namespace), + fmt.Sprintf("%s.%s", service, meta.Namespace), + service, + } +} + func ServiceFQDNWithService(meta metav1.ObjectMeta, service string) string { clusterName := os.Getenv("CLUSTER_NAME") if clusterName == "" { diff --git a/pkg/version/version.go b/pkg/version/version.go index 962ee8e977..04e794d35c 100644 --- a/pkg/version/version.go +++ b/pkg/version/version.go @@ -3,21 +3,93 @@ package version import ( "fmt" + "github.com/go-logr/logr" "github.com/hashicorp/go-version" "github.com/mariadb-operator/mariadb-operator/pkg/docker" ) -func GetMinorVersion(image string) (string, error) { - tag, err := docker.GetTag(image) +// Option represents a function that applies a configuration to a Version instance. +type Option func(opts *Options) + +// WithDefaultVersion sets a default version if parsing the Docker image tag fails. +func WithDefaultVersion(defaultVersion string) Option { + return func(opts *Options) { + opts.defaultVersion = defaultVersion + } +} + +// WithLogger sets a logger for the Version instance. +func WithLogger(logger logr.Logger) Option { + return func(opts *Options) { + opts.logger = logger + } +} + +// Options to be used with Version. +type Options struct { + defaultVersion string + logger logr.Logger +} + +// Version wraps a HashiCorp version.Version struct to provide additional functionalities for better convenience. +type Version struct { + innerVersion version.Version +} + +// GetMinorVersion extracts and returns the "major.minor" part of the version. +func (v *Version) GetMinorVersion() (string, error) { + segments := v.innerVersion.Segments() + if len(segments) < 2 { + return "", fmt.Errorf("invalid version: %v", v.innerVersion.String()) + } + return fmt.Sprintf("%d.%d", segments[0], segments[1]), nil +} + +// Compare compares the current version with another semantic version string. +func (v *Version) Compare(other string) (int, error) { + otherVersion, err := version.NewSemver(other) if err != nil { - return "", fmt.Errorf("invalid image: %v", err) + return 0, fmt.Errorf("error parsing version '%s': %v", other, err) } + return v.innerVersion.Compare(otherVersion), nil +} - v, err := version.NewSemver(tag) +// GreaterThanOrEqual checks if the current version is greater than or equal to another version. +func (v *Version) GreaterThanOrEqual(other string) (bool, error) { + result, err := v.Compare(other) if err != nil { - return "", fmt.Errorf("error parsing version: %v", err) + return false, fmt.Errorf("error comparing versions: %v", err) } - segments := v.Segments() + return result >= 0, nil +} - return fmt.Sprintf("%d.%d", segments[0], segments[1]), nil +// NewVersion constructs a new Version instance from a given Docker image tag. +func NewVersion(image string, vOpts ...Option) (*Version, error) { + opts := Options{ + logger: logr.Discard(), + } + for _, opt := range vOpts { + opt(&opts) + } + + var versions []string + tag, err := docker.GetTag(image) + if err != nil { + opts.logger.Error(err, "unable to parse tag from image", "image", image) + } else { + versions = append(versions, tag) + } + if opts.defaultVersion != "" { + opts.logger.V(1).Info("configuring default version", "version", opts.defaultVersion) + versions = append(versions, opts.defaultVersion) + } + + for _, v := range versions { + innerVersion, err := version.NewSemver(v) + if err == nil { + return &Version{innerVersion: *innerVersion}, nil + } + opts.logger.Error(err, "unable to parse version", "version", v) + } + return nil, fmt.Errorf("unable to parse version from image \"%s\" nor default image", image) } diff --git a/pkg/version/version_test.go b/pkg/version/version_test.go index 0da6872ed2..2aac448372 100644 --- a/pkg/version/version_test.go +++ b/pkg/version/version_test.go @@ -1,93 +1,251 @@ package version -import "testing" +import ( + "testing" + + "github.com/google/go-cmp/cmp" +) func TestGetMinorVersion(t *testing.T) { tests := []struct { name string image string + defaultVersion string wantMinorVersion string wantErr bool }{ { name: "empty", image: "", + defaultVersion: "", wantMinorVersion: "", wantErr: true, }, + { + name: "empty with default", + image: "", + defaultVersion: "10.11.8", + wantMinorVersion: "10.11", + wantErr: false, + }, { name: "invalid image", image: "10.11.8", + defaultVersion: "", wantMinorVersion: "", wantErr: true, }, { - name: "latest", + name: "invalid image with default", + image: "10.11.8", + defaultVersion: "11.4", + wantMinorVersion: "11.4", + wantErr: false, + }, + { + name: "non semver", image: "mariadb:latest", + defaultVersion: "", wantMinorVersion: "", wantErr: true, }, { - name: "lts", - image: "mariadb:lts-noble", + name: "non semver with default", + image: "mariadb:latest", + defaultVersion: "10.6", + wantMinorVersion: "10.6", + wantErr: false, + }, + { + name: "sha256", + image: "mariadb@sha256:3f48454b6a33e094af6d23ced54645ec0533cb11854d07738920852ca48e390d", + defaultVersion: "", wantMinorVersion: "", wantErr: true, }, + { + name: "sha256 with default", + image: "mariadb@sha256:3f48454b6a33e094af6d23ced54645ec0533cb11854d07738920852ca48e390d", + defaultVersion: "11.4", + wantMinorVersion: "11.4", + wantErr: false, + }, { name: "major", image: "mariadb:10", + defaultVersion: "", wantMinorVersion: "10.0", wantErr: false, }, { name: "major + minor", image: "mariadb:10.11", + defaultVersion: "", wantMinorVersion: "10.11", wantErr: false, }, { name: "major + minor + patch", image: "mariadb:10.11.8", + defaultVersion: "", wantMinorVersion: "10.11", wantErr: false, }, { name: "major + minor + patch + prerelease", image: "mariadb:10.11.8-ubi", + defaultVersion: "", wantMinorVersion: "10.11", wantErr: false, }, { - name: "enterprise: latest", + name: "enterprise non semver", image: "docker.mariadb.com/enterprise-server:latest", + defaultVersion: "", wantMinorVersion: "", wantErr: true, }, { - name: "enterprise: major + minor", + name: "enterprise non semver with default", + image: "docker.mariadb.com/enterprise-server:latest", + defaultVersion: "10.6", + wantMinorVersion: "10.6", + wantErr: false, + }, + { + name: "enterprise major + minor", image: "docker.mariadb.com/enterprise-server:10.6", + defaultVersion: "", wantMinorVersion: "10.6", wantErr: false, }, { - name: "enterprise: major + minor + patch + prerelease", + name: "enterprise major + minor + patch + prerelease", image: "docker.mariadb.com/enterprise-server:10.6.18-14", + defaultVersion: "", wantMinorVersion: "10.6", wantErr: false, }, + { + name: "enterprise sha256", + image: "docker.mariadb.com/enterprise-server@sha256:3f48454b6a33e094af6d23ced54645ec0533cb11854d07738920852ca48e390d", + defaultVersion: "", + wantMinorVersion: "", + wantErr: true, + }, + { + name: "enterprise sha256 with default", + image: "docker.mariadb.com/enterprise-server@sha256:3f48454b6a33e094af6d23ced54645ec0533cb11854d07738920852ca48e390d", + defaultVersion: "10.6", + wantMinorVersion: "10.6", + wantErr: false, + }, + { + name: "invalid default", + image: "docker.mariadb.com/enterprise-server@sha256:3f48454b6a33e094af6d23ced54645ec0533cb11854d07738920852ca48e390d", + defaultVersion: "latest", + wantMinorVersion: "", + wantErr: true, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - minorVersion, err := GetMinorVersion(tt.image) - if tt.wantMinorVersion != minorVersion { - t.Errorf("want minor version \"%s\", got: \"%s\"", tt.wantMinorVersion, minorVersion) + var opts []Option + if tt.defaultVersion != "" { + opts = append(opts, WithDefaultVersion(tt.defaultVersion)) } + + version, err := NewVersion(tt.image, opts...) + if tt.wantErr && err == nil { + t.Error("expected error but got nil") + } + if !tt.wantErr && err != nil { + t.Errorf("unexpected error creating version: %v", err) + } + + if !tt.wantErr { + minorVersion, err := version.GetMinorVersion() + if err != nil { + t.Errorf("unexpected error getting minor version: %v", err) + } + if diff := cmp.Diff(tt.wantMinorVersion, minorVersion); diff != "" { + t.Errorf("unexpected minor version (-want +got):\n%s", diff) + } + } + }) + } +} + +func TestGreaterThanOrEqual(t *testing.T) { + tests := []struct { + name string + image string + otherVersion string + wantBool bool + wantErr bool + }{ + { + name: "empty", + image: "mariadb:10.11.8", + otherVersion: "", + wantBool: false, + wantErr: true, + }, + { + name: "non semver", + image: "mariadb:10.11.8", + otherVersion: "latest", + wantBool: false, + wantErr: true, + }, + { + name: "greater than", + image: "mariadb:10.11.8", + otherVersion: "10.6", + wantBool: true, + wantErr: false, + }, + { + name: "greater than minor", + image: "mariadb:10.11.8", + otherVersion: "10.11", + wantBool: true, + wantErr: false, + }, + { + name: "equal", + image: "mariadb:10.11.8", + otherVersion: "10.11.8", + wantBool: true, + wantErr: false, + }, + { + name: "less than", + image: "mariadb:10.11.8", + otherVersion: "11.4.3", + wantBool: false, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + version, err := NewVersion(tt.image) + if err != nil { + t.Errorf("unexpected error creating version: %v", err) + } + + gotBool, err := version.GreaterThanOrEqual(tt.otherVersion) if tt.wantErr && err == nil { t.Error("expected error but got nil") } if !tt.wantErr && err != nil { - t.Errorf("unexpected error getting minor version: %v", err) + t.Errorf("unexpected error checking version: %v", err) + } + + if diff := cmp.Diff(tt.wantBool, gotBool); diff != "" { + t.Errorf("unexpected bool (-want +got):\n%s", diff) } }) } diff --git a/pkg/watch/watch.go b/pkg/watch/watch.go index b296effae6..7d6a40ed34 100644 --- a/pkg/watch/watch.go +++ b/pkg/watch/watch.go @@ -5,6 +5,7 @@ import ( "fmt" "reflect" + "github.com/go-logr/logr" "k8s.io/apimachinery/pkg/fields" "k8s.io/apimachinery/pkg/types" ctrl "sigs.k8s.io/controller-runtime" @@ -12,6 +13,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client" ctrlclient "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/handler" + "sigs.k8s.io/controller-runtime/pkg/log" "sigs.k8s.io/controller-runtime/pkg/reconcile" ) @@ -44,36 +46,48 @@ func NewWatcherIndexer(mgr ctrl.Manager, builder *builder.Builder, client ctrlcl } } -func (rw *WatcherIndexer) Watch(ctx context.Context, obj client.Object, indexer Indexer, indexerList ItemLister, +func (i *WatcherIndexer) Watch(ctx context.Context, obj client.Object, indexer Indexer, indexerList ItemLister, indexerFieldPath string, opts ...builder.WatchesOption) error { + logger := log.FromContext(ctx). + WithName("indexer"). + WithValues( + "kind", getKind(indexer), + "field", indexerFieldPath, + ) + logger.Info("Watching field") + indexerFn, err := indexer.IndexerFuncForFieldPath(indexerFieldPath) if err != nil { return fmt.Errorf("error getting indexer func: %v", err) } - if err := rw.mgr.GetFieldIndexer().IndexField(ctx, indexer, indexerFieldPath, indexerFn); err != nil { + if err := i.mgr.GetFieldIndexer().IndexField(ctx, indexer, indexerFieldPath, func(o ctrlclient.Object) []string { + logger.V(1).Info("Indexing field", "name", o.GetName(), "namespace", o.GetNamespace()) + return indexerFn(o) + }); err != nil { return fmt.Errorf("error indexing '%s' field: %v", indexerFieldPath, err) } - rw.builder.Watches( + i.builder.Watches( obj, handler.EnqueueRequestsFromMapFunc(func(ctx context.Context, o ctrlclient.Object) []reconcile.Request { - return rw.mapWatchedObjectToRequests(ctx, o, indexerList, indexerFieldPath) + return i.mapWatchedObjectToRequests(ctx, o, indexerList, indexerFieldPath, logger) }), opts..., ) return nil } -func (rw *WatcherIndexer) mapWatchedObjectToRequests(ctx context.Context, obj ctrlclient.Object, indexList ItemLister, - indexerFieldPath string) []reconcile.Request { +func (i *WatcherIndexer) mapWatchedObjectToRequests(ctx context.Context, obj ctrlclient.Object, indexList ItemLister, + indexerFieldPath string, logger logr.Logger) []reconcile.Request { indexersToReconcile := NewItemListerOfType(indexList) listOpts := &ctrlclient.ListOptions{ FieldSelector: fields.OneTermEqualSelector(indexerFieldPath, obj.GetName()), Namespace: obj.GetNamespace(), } - if err := rw.client.List(ctx, indexersToReconcile, listOpts); err != nil { + if err := i.client.List(ctx, indexersToReconcile, listOpts); err != nil { + logger.Error(err, "name", obj.GetName(), "namespace", obj.GetNamespace()) return []reconcile.Request{} } @@ -89,3 +103,11 @@ func (rw *WatcherIndexer) mapWatchedObjectToRequests(ctx context.Context, obj ct } return requests } + +func getKind(obj client.Object) string { + kind := obj.GetObjectKind().GroupVersionKind().Kind + if kind != "" { + return kind + } + return reflect.TypeOf(obj).Elem().Name() +} pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy