diff --git a/PROJECT b/PROJECT index 4c9c403..f6277b6 100644 --- a/PROJECT +++ b/PROJECT @@ -19,10 +19,11 @@ resources: version: v1alpha1 - api: crdVersion: v1 + namespaced: true controller: true domain: lagoon.sh group: crd - kind: DatabaseMySQLProvider + kind: RelationalDatabaseProvider path: github.com/uselagoon/dbaas-controller/api/v1alpha1 version: v1alpha1 version: "3" diff --git a/README.md b/README.md index e6852e8..2b96a32 100644 --- a/README.md +++ b/README.md @@ -9,13 +9,13 @@ It allows for provisiong and deprovisioning of shared MySQL/MariaDB, PostgreSQL, ## Current Status of the Project WIP - Work in Progress -There is still a lot of work to be done on this project. The current status is that the controller is able to provision and deprovision MySQL databases. But there is still a lot of work to be done to make it production ready. +There is still a some work to be done on this project. The current status is that the controller is able to provision and deprovision MySQL databases. But there is still a lot of work to be done to make it production ready. - [x] Setup e2e tests - [x] Provision MySQL databases (basic) - no support for additional users, seeding, etc. - [x] Deprovision MySQL databases -- [ ] Provision PostgreSQL databases -- [ ] Deprovision PostgreSQL databases +- [x] Provision PostgreSQL databases +- [x] Deprovision PostgreSQL databases - [ ] Provision MongoDB databases - [ ] Deprovision MongoDB databases - [ ] Plan to migrate from old `dbaaas-operator` to `dbaas-controller` @@ -58,8 +58,8 @@ Key Features: To interact with the dbaas-controller, the following CRDs are introduced: -- DatabaseXProvider - - This CRD is used to define a database provider, such as MySQL, PostgreSQL, or MongoDB. +- RelationalDatabaseProvider + - This CRD is used to define a database provider, such as MySQL and PostgreSQL. - DatabaseRequest - DatabaseMigration @@ -67,48 +67,50 @@ Basic usage of the CRs in combination with the dbaas-controller is outlined belo - DatabaseRequest: Lagoon creates a DatabaseRequest CR to request a database instance - The dbaas-controller processes the request and provisions the database instance based on the request - - The controller uses the relevant DatabaseXProvider CR to determine how it should provision the database + - The controller uses the relevant RelationalDatabaseProvider CR to determine how it should provision the database -## DatabaseMySQLProvider CRD Documentation +## RelationalDatabaseProvider CRD Documentation -The `DatabaseMySQLProvider` CRD defines a Kubernetes-native way to manage MySQL database connections and configurations. This custom resource allows to define MySQL databases. +The `RelationalDatabaseProvider` CRD defines a Kubernetes-native way to manage relational database connections and configurations. This custom resource allows to define MySQL and PostgreSQL databases. -Use the status mysqlConnectionStatus field to check the status of the MySQL connections defined in the spec. +Use the status connectionStatus field to check the status of the MySQL connections defined in the spec. -### DatabaseMySQLProvider Spec Fields +### RelationalDatabaseProvider Spec Fields +- kind (required): The type of database provider, which can be either mysql or postgresql. - scope (required): Defines the scope of the database request, which influences the environment setup. Valid values are production, development, and custom. Defaults to development if not specified. -- mysqlConnections (required): A list of `MySQLConnection` objects that detail the connection parameters to MySQL databases. At least one connection must be defined. +- connections (required): A list of `connection` objects that detail the connection parameters to MySQL or PostgreSQL databases. At least one connection must be defined. -- MySQLConnection Fields - - name (required): A unique name for the MySQL database connection, used to identify and reference the connection in database requests. - - hostname (required): The hostname of the MySQL database server. - - replicaHostnames (optional): A list of hostnames for the MySQL replica databases. +- connection Fields + - name (required): A unique name for the MySQL or PostgreSQL database connection, used to identify and reference the connection in database requests. + - hostname (required): The hostname of the MySQL or PostgreSQL database server. + - replicaHostnames (optional): A list of hostnames for the MySQLi or PostgreSQL replica databases. - passwordSecretRef (required): A reference to a Kubernetes Secret containing the password for the database connection. - - port (required): The port on which the MySQL database server is listening. Must be between 1 and 65535. - - username (required): The username for logging into the MySQL database. + - port (required): The port on which the MySQLi or PostgreSQL database server is listening. Must be between 1 and 65535. + - username (required): The username for logging into the MySQLi or PostgreSQL database. - enabled (required): A flag indicating whether this database connection is enabled. Defaults to true. -### DatabaseMySQLProvider Status Fields +### RelationalDatabaseProvider Status Fields -- conditions: Provides detailed conditions of the MySQLProvider like readiness, errors, etc. -- mysqlConnectionStatus: A list of statuses for the MySQL connections defined in the spec. +- conditions: Provides detailed conditions of the `RelationalDatabaseProvider` like readiness, errors, etc. +- connectionStatus: A list of statuses for the MySQL or PostgreSQL connections defined in the spec. -- MySQLConnectionStatus Fields - - hostname (required): The hostname of the MySQL database server. - - mysqlVersion (required): The version of the MySQL server. +- connectionStatus Fields + - hostname (required): The hostname of the MySQL or PostgreSQL database server. + - mysqlVersion (required): The version of the MySQL or PostgreSQL server. - enabled (required): Indicates if the database connection is enabled. - status (required): The current status of the database connection, with valid values being available and unavailable. -- observedGeneration: Reflects the generation of the most recently observed DatabaseMySQLProvider object. +- observedGeneration: Reflects the generation of the most recently observed RelationalDatabaseProvider object. -### DatabaseMySQLProvider Example +### RelationalDatabaseProvider Example ```yaml apiVersion: v1alpha1 -kind: DatabaseMySQLProvider +kind: RelationalDatabaseProvider metadata: name: example-mysql-provider spec: + kind: mysql scope: development mysqlConnections: - name: primary-db diff --git a/api/v1alpha1/databasemysqlprovider_types.go b/api/v1alpha1/relationaldatabaseprovider_types.go similarity index 56% rename from api/v1alpha1/databasemysqlprovider_types.go rename to api/v1alpha1/relationaldatabaseprovider_types.go index af04f03..a237586 100644 --- a/api/v1alpha1/databasemysqlprovider_types.go +++ b/api/v1alpha1/relationaldatabaseprovider_types.go @@ -21,21 +21,21 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) -// MySQLConnection defines the connection to a MySQL database -type MySQLConnection struct { - // Name is the name of the MySQL database connection +// Connection defines the connection to a relational database like MySQL or PostgreSQL +type Connection struct { + // Name is the name of the relational database like MySQL or PostgreSQL connection // it is used to identify the connection. Please use a unique name // for each connection. This field will be used in the DatabaseRequest - // to reference the connection. The databasemysqlprovider controller will + // to reference the connection. The relationaldatabaseprovider controller will // error if the name is not unique. Name string `json:"name"` //+kubebuilder:required - // Hostname is the hostname of the MySQL database + // Hostname is the hostname of the relational database Hostname string `json:"hostname"` //+kubebuilder:optional - // ReplicaHostnames is the list of hostnames of the MySQL database replicas + // ReplicaHostnames is the list of hostnames of the relational database replicas ReplicaHostnames []string `json:"replicaHostnames,omitempty"` //+kubebuilder:required @@ -46,21 +46,28 @@ type MySQLConnection struct { //+kubebuilder:validation:Required //+kubebuilder:validation:Minimum=1 //+kubebuilder:validation:Maximum=65535 - // Port is the port of the MySQL database + // Port is the port of the relational database Port int `json:"port"` //+kubebuilder:required - // Username is the username of the MySQL database + // Username is the username of the relational database Username string `json:"username"` //+kubebuilder:required //+kubebuilder:default:=true - // Enabled is a flag to enable or disable the MySQL database + // Enabled is a flag to enable or disable the relational database Enabled bool `json:"enabled"` } -// DatabaseMySQLProviderSpec defines the desired state of DatabaseMySQLProvider -type DatabaseMySQLProviderSpec struct { +// RelationalDatabaseProviderSpec defines the desired state of RelationalDatabaseProvider +type RelationalDatabaseProviderSpec struct { + //+kubebuilder:required + //+kubebuilder:validation:Required + //+kubebuilder:validation:Enum=mysql;postgresql + // Kind is the kind of the relational database provider + // it can be either "mysql" or "postgresql" + Kind string `json:"kind"` + //+kubebuilder:required //+kubebuilder:validation:Required //+kubebuilder:validation:Enum=production;development;custom @@ -70,27 +77,27 @@ type DatabaseMySQLProviderSpec struct { Scope string `json:"scope"` //+kubebuilder:validation:MinItems=1 - // MySQLConnections defines the connection to a MySQL database - MySQLConnections []MySQLConnection `json:"mysqlConnections"` + // Connections defines the connection to a relational database + Connections []Connection `json:"connections"` } -// MySQLConnectionStatus defines the status of a MySQL database connection -type MySQLConnectionStatus struct { +// ConnectionStatus defines the status of a relational database connection +type ConnectionStatus struct { //+kubebuilder:required - // Name is the name of the MySQL database connection + // Name is the name of the relational database connection // it is used to identify the connection. Please use a unique name // for each connection. This field will be used in the DatabaseRequest - // to reference the connection. The databasemysqlprovider controller will + // to reference the connection. The relationaldatabaseprovider controller will // error if the name is not unique. Name string `json:"name"` //+kubebuilder:required - // Hostname is the hostname of the MySQL database + // Hostname is the hostname of the relational database Hostname string `json:"hostname"` //+kubebuilder:required - // MySQLVersion is the version of the MySQL database - MySQLVersion string `json:"mysqlVersion"` + // DatabaseVersion is the version of the relational database + DatabaseVersion string `json:"databaseVersion"` //+kubebuilder:required //+kubebuilder:validation:Required @@ -100,17 +107,17 @@ type MySQLConnectionStatus struct { //+kubebuilder:required //+kubebuilder:validation:Required //+kubebuilder:validation:Enum=available;unavailable - // Status is the status of the MySQL database + // Status is the status of the relational database Status string `json:"status"` } -// DatabaseMySQLProviderStatus defines the observed state of DatabaseMySQLProvider -type DatabaseMySQLProviderStatus struct { +// RelationalDatabaseProviderStatus defines the observed state of RelationalDatabaseProvider +type RelationalDatabaseProviderStatus struct { // Conditions defines the status conditions Conditions []metav1.Condition `json:"conditions,omitempty"` - // MySQLConnectionStatus provides the status of the MySQL database - MySQLConnectionStatus []MySQLConnectionStatus `json:"mysqlConnectionStatus,omitempty"` + // ConnectionStatus provides the status of the relational database + ConnectionStatus []ConnectionStatus `json:"connectionStatus,omitempty"` // nolint:lll // ObservedGeneration is the last observed generation ObservedGeneration int64 `json:"observedGeneration,omitempty"` @@ -120,24 +127,24 @@ type DatabaseMySQLProviderStatus struct { //+kubebuilder:subresource:status //+kubebuilder:resource:scope=Cluster -// DatabaseMySQLProvider is the Schema for the databasemysqlproviders API -type DatabaseMySQLProvider struct { +// RelationalDatabaseProvider is the Schema for the relationaldatabaseprovider API +type RelationalDatabaseProvider struct { metav1.TypeMeta `json:",inline"` metav1.ObjectMeta `json:"metadata,omitempty"` - Spec DatabaseMySQLProviderSpec `json:"spec,omitempty"` - Status DatabaseMySQLProviderStatus `json:"status,omitempty"` + Spec RelationalDatabaseProviderSpec `json:"spec,omitempty"` + Status RelationalDatabaseProviderStatus `json:"status,omitempty"` } //+kubebuilder:object:root=true -// DatabaseMySQLProviderList contains a list of DatabaseMySQLProvider -type DatabaseMySQLProviderList struct { +// RelationalDatabaseProviderList contains a list of RelationalDatabaseProvider +type RelationalDatabaseProviderList struct { metav1.TypeMeta `json:",inline"` metav1.ListMeta `json:"metadata,omitempty"` - Items []DatabaseMySQLProvider `json:"items"` + Items []RelationalDatabaseProvider `json:"items"` } func init() { - SchemeBuilder.Register(&DatabaseMySQLProvider{}, &DatabaseMySQLProviderList{}) + SchemeBuilder.Register(&RelationalDatabaseProvider{}, &RelationalDatabaseProviderList{}) } diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index 5fbe96c..4006b13 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -21,8 +21,8 @@ limitations under the License. package v1alpha1 import ( - corev1 "k8s.io/api/core/v1" - "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" runtime "k8s.io/apimachinery/pkg/runtime" ) @@ -42,140 +42,68 @@ func (in *AdditionalUser) DeepCopy() *AdditionalUser { } // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *DatabaseConnectionReference) DeepCopyInto(out *DatabaseConnectionReference) { +func (in *Connection) DeepCopyInto(out *Connection) { *out = *in - out.DatabaseObjectReference = in.DatabaseObjectReference -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DatabaseConnectionReference. -func (in *DatabaseConnectionReference) DeepCopy() *DatabaseConnectionReference { - if in == nil { - return nil - } - out := new(DatabaseConnectionReference) - in.DeepCopyInto(out) - return out -} - -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *DatabaseInfo) DeepCopyInto(out *DatabaseInfo) { - *out = *in -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DatabaseInfo. -func (in *DatabaseInfo) DeepCopy() *DatabaseInfo { - if in == nil { - return nil + if in.ReplicaHostnames != nil { + in, out := &in.ReplicaHostnames, &out.ReplicaHostnames + *out = make([]string, len(*in)) + copy(*out, *in) } - out := new(DatabaseInfo) - in.DeepCopyInto(out) - return out -} - -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *DatabaseMySQLProvider) DeepCopyInto(out *DatabaseMySQLProvider) { - *out = *in - out.TypeMeta = in.TypeMeta - in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) - in.Spec.DeepCopyInto(&out.Spec) - in.Status.DeepCopyInto(&out.Status) + out.PasswordSecretRef = in.PasswordSecretRef } -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DatabaseMySQLProvider. -func (in *DatabaseMySQLProvider) DeepCopy() *DatabaseMySQLProvider { +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Connection. +func (in *Connection) DeepCopy() *Connection { if in == nil { return nil } - out := new(DatabaseMySQLProvider) + out := new(Connection) in.DeepCopyInto(out) return out } -// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. -func (in *DatabaseMySQLProvider) DeepCopyObject() runtime.Object { - if c := in.DeepCopy(); c != nil { - return c - } - return nil -} - // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *DatabaseMySQLProviderList) DeepCopyInto(out *DatabaseMySQLProviderList) { +func (in *ConnectionStatus) DeepCopyInto(out *ConnectionStatus) { *out = *in - out.TypeMeta = in.TypeMeta - in.ListMeta.DeepCopyInto(&out.ListMeta) - if in.Items != nil { - in, out := &in.Items, &out.Items - *out = make([]DatabaseMySQLProvider, len(*in)) - for i := range *in { - (*in)[i].DeepCopyInto(&(*out)[i]) - } - } } -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DatabaseMySQLProviderList. -func (in *DatabaseMySQLProviderList) DeepCopy() *DatabaseMySQLProviderList { +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ConnectionStatus. +func (in *ConnectionStatus) DeepCopy() *ConnectionStatus { if in == nil { return nil } - out := new(DatabaseMySQLProviderList) + out := new(ConnectionStatus) in.DeepCopyInto(out) return out } -// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. -func (in *DatabaseMySQLProviderList) DeepCopyObject() runtime.Object { - if c := in.DeepCopy(); c != nil { - return c - } - return nil -} - // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *DatabaseMySQLProviderSpec) DeepCopyInto(out *DatabaseMySQLProviderSpec) { +func (in *DatabaseConnectionReference) DeepCopyInto(out *DatabaseConnectionReference) { *out = *in - if in.MySQLConnections != nil { - in, out := &in.MySQLConnections, &out.MySQLConnections - *out = make([]MySQLConnection, len(*in)) - for i := range *in { - (*in)[i].DeepCopyInto(&(*out)[i]) - } - } + out.DatabaseObjectReference = in.DatabaseObjectReference } -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DatabaseMySQLProviderSpec. -func (in *DatabaseMySQLProviderSpec) DeepCopy() *DatabaseMySQLProviderSpec { +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DatabaseConnectionReference. +func (in *DatabaseConnectionReference) DeepCopy() *DatabaseConnectionReference { if in == nil { return nil } - out := new(DatabaseMySQLProviderSpec) + out := new(DatabaseConnectionReference) in.DeepCopyInto(out) return out } // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *DatabaseMySQLProviderStatus) DeepCopyInto(out *DatabaseMySQLProviderStatus) { +func (in *DatabaseInfo) DeepCopyInto(out *DatabaseInfo) { *out = *in - if in.Conditions != nil { - in, out := &in.Conditions, &out.Conditions - *out = make([]v1.Condition, len(*in)) - for i := range *in { - (*in)[i].DeepCopyInto(&(*out)[i]) - } - } - if in.MySQLConnectionStatus != nil { - in, out := &in.MySQLConnectionStatus, &out.MySQLConnectionStatus - *out = make([]MySQLConnectionStatus, len(*in)) - copy(*out, *in) - } } -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DatabaseMySQLProviderStatus. -func (in *DatabaseMySQLProviderStatus) DeepCopy() *DatabaseMySQLProviderStatus { +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DatabaseInfo. +func (in *DatabaseInfo) DeepCopy() *DatabaseInfo { if in == nil { return nil } - out := new(DatabaseMySQLProviderStatus) + out := new(DatabaseInfo) in.DeepCopyInto(out) return out } @@ -244,7 +172,7 @@ func (in *DatabaseRequestSpec) DeepCopyInto(out *DatabaseRequestSpec) { *out = *in if in.Seed != nil { in, out := &in.Seed, &out.Seed - *out = new(corev1.SecretReference) + *out = new(v1.SecretReference) **out = **in } if in.AdditionalUsers != nil { @@ -278,7 +206,7 @@ func (in *DatabaseRequestStatus) DeepCopyInto(out *DatabaseRequestStatus) { *out = *in if in.Conditions != nil { in, out := &in.Conditions, &out.Conditions - *out = make([]v1.Condition, len(*in)) + *out = make([]metav1.Condition, len(*in)) for i := range *in { (*in)[i].DeepCopyInto(&(*out)[i]) } @@ -306,37 +234,109 @@ func (in *DatabaseRequestStatus) DeepCopy() *DatabaseRequestStatus { } // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *MySQLConnection) DeepCopyInto(out *MySQLConnection) { +func (in *RelationalDatabaseProvider) DeepCopyInto(out *RelationalDatabaseProvider) { *out = *in - if in.ReplicaHostnames != nil { - in, out := &in.ReplicaHostnames, &out.ReplicaHostnames - *out = make([]string, len(*in)) - copy(*out, *in) + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + in.Spec.DeepCopyInto(&out.Spec) + in.Status.DeepCopyInto(&out.Status) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RelationalDatabaseProvider. +func (in *RelationalDatabaseProvider) DeepCopy() *RelationalDatabaseProvider { + if in == nil { + return nil } - out.PasswordSecretRef = in.PasswordSecretRef + out := new(RelationalDatabaseProvider) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *RelationalDatabaseProvider) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil } -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MySQLConnection. -func (in *MySQLConnection) DeepCopy() *MySQLConnection { +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *RelationalDatabaseProviderList) DeepCopyInto(out *RelationalDatabaseProviderList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]RelationalDatabaseProvider, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RelationalDatabaseProviderList. +func (in *RelationalDatabaseProviderList) DeepCopy() *RelationalDatabaseProviderList { + if in == nil { + return nil + } + out := new(RelationalDatabaseProviderList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *RelationalDatabaseProviderList) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *RelationalDatabaseProviderSpec) DeepCopyInto(out *RelationalDatabaseProviderSpec) { + *out = *in + if in.Connections != nil { + in, out := &in.Connections, &out.Connections + *out = make([]Connection, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RelationalDatabaseProviderSpec. +func (in *RelationalDatabaseProviderSpec) DeepCopy() *RelationalDatabaseProviderSpec { if in == nil { return nil } - out := new(MySQLConnection) + out := new(RelationalDatabaseProviderSpec) in.DeepCopyInto(out) return out } // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *MySQLConnectionStatus) DeepCopyInto(out *MySQLConnectionStatus) { +func (in *RelationalDatabaseProviderStatus) DeepCopyInto(out *RelationalDatabaseProviderStatus) { *out = *in + if in.Conditions != nil { + in, out := &in.Conditions, &out.Conditions + *out = make([]metav1.Condition, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + if in.ConnectionStatus != nil { + in, out := &in.ConnectionStatus, &out.ConnectionStatus + *out = make([]ConnectionStatus, len(*in)) + copy(*out, *in) + } } -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MySQLConnectionStatus. -func (in *MySQLConnectionStatus) DeepCopy() *MySQLConnectionStatus { +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RelationalDatabaseProviderStatus. +func (in *RelationalDatabaseProviderStatus) DeepCopy() *RelationalDatabaseProviderStatus { if in == nil { return nil } - out := new(MySQLConnectionStatus) + out := new(RelationalDatabaseProviderStatus) in.DeepCopyInto(out) return out } diff --git a/cmd/main.go b/cmd/main.go index b22f03d..adb30dd 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -36,7 +36,7 @@ import ( crdv1alpha1 "github.com/uselagoon/dbaas-controller/api/v1alpha1" "github.com/uselagoon/dbaas-controller/internal/controller" - "github.com/uselagoon/dbaas-controller/internal/database/mysql" + "github.com/uselagoon/dbaas-controller/internal/database" //+kubebuilder:scaffold:imports ) @@ -126,24 +126,25 @@ func main() { os.Exit(1) } + relDBClient := database.New() + if err = (&controller.DatabaseRequestReconciler{ - Client: mgr.GetClient(), - Scheme: mgr.GetScheme(), - MySQLClient: &mysql.MySQLImpl{}, + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + RelationalDatabaseClient: relDBClient, }).SetupWithManager(mgr, maxConcurrentReconciles); err != nil { setupLog.Error(err, "unable to create controller", "controller", "DatabaseRequest") os.Exit(1) } - if err = (&controller.DatabaseMySQLProviderReconciler{ + if err = (&controller.RelationalDatabaseProviderReconciler{ Client: mgr.GetClient(), Scheme: mgr.GetScheme(), - MySQLClient: &mysql.MySQLImpl{}, + RelDBClient: relDBClient, }).SetupWithManager(mgr); err != nil { - setupLog.Error(err, "unable to create controller", "controller", "DatabaseMySQLProvider") + setupLog.Error(err, "unable to create controller", "controller", "RelationalDatabaseProvider") os.Exit(1) } //+kubebuilder:scaffold:builder - if err := mgr.AddHealthzCheck("healthz", healthz.Ping); err != nil { setupLog.Error(err, "unable to set up health check") os.Exit(1) diff --git a/config/crd/bases/crd.lagoon.sh_databasemysqlproviders.yaml b/config/crd/bases/crd.lagoon.sh_relationaldatabaseproviders.yaml similarity index 81% rename from config/crd/bases/crd.lagoon.sh_databasemysqlproviders.yaml rename to config/crd/bases/crd.lagoon.sh_relationaldatabaseproviders.yaml index 230de27..601e3a3 100644 --- a/config/crd/bases/crd.lagoon.sh_databasemysqlproviders.yaml +++ b/config/crd/bases/crd.lagoon.sh_relationaldatabaseproviders.yaml @@ -4,20 +4,20 @@ kind: CustomResourceDefinition metadata: annotations: controller-gen.kubebuilder.io/version: v0.14.0 - name: databasemysqlproviders.crd.lagoon.sh + name: relationaldatabaseproviders.crd.lagoon.sh spec: group: crd.lagoon.sh names: - kind: DatabaseMySQLProvider - listKind: DatabaseMySQLProviderList - plural: databasemysqlproviders - singular: databasemysqlprovider + kind: RelationalDatabaseProvider + listKind: RelationalDatabaseProviderList + plural: relationaldatabaseproviders + singular: relationaldatabaseprovider scope: Cluster versions: - name: v1alpha1 schema: openAPIV3Schema: - description: DatabaseMySQLProvider is the Schema for the databasemysqlproviders + description: RelationalDatabaseProvider is the Schema for the relationaldatabaseprovider API properties: apiVersion: @@ -38,27 +38,29 @@ spec: metadata: type: object spec: - description: DatabaseMySQLProviderSpec defines the desired state of DatabaseMySQLProvider + description: RelationalDatabaseProviderSpec defines the desired state + of RelationalDatabaseProvider properties: - mysqlConnections: - description: MySQLConnections defines the connection to a MySQL database + connections: + description: Connections defines the connection to a relational database items: - description: MySQLConnection defines the connection to a MySQL database + description: Connection defines the connection to a relational database + like MySQL or PostgreSQL properties: enabled: default: true - description: Enabled is a flag to enable or disable the MySQL + description: Enabled is a flag to enable or disable the relational database type: boolean hostname: - description: Hostname is the hostname of the MySQL database + description: Hostname is the hostname of the relational database type: string name: description: |- - Name is the name of the MySQL database connection + Name is the name of the relational database like MySQL or PostgreSQL connection it is used to identify the connection. Please use a unique name for each connection. This field will be used in the DatabaseRequest - to reference the connection. The databasemysqlprovider controller will + to reference the connection. The relationaldatabaseprovider controller will error if the name is not unique. type: string passwordSecretRef: @@ -76,18 +78,18 @@ spec: type: object x-kubernetes-map-type: atomic port: - description: Port is the port of the MySQL database + description: Port is the port of the relational database maximum: 65535 minimum: 1 type: integer replicaHostnames: description: ReplicaHostnames is the list of hostnames of the - MySQL database replicas + relational database replicas items: type: string type: array username: - description: Username is the username of the MySQL database + description: Username is the username of the relational database type: string required: - enabled @@ -99,6 +101,14 @@ spec: type: object minItems: 1 type: array + kind: + description: |- + Kind is the kind of the relational database provider + it can be either "mysql" or "postgresql" + enum: + - mysql + - postgresql + type: string scope: default: development description: |- @@ -110,12 +120,13 @@ spec: - custom type: string required: - - mysqlConnections + - connections + - kind - scope type: object status: - description: DatabaseMySQLProviderStatus defines the observed state of - DatabaseMySQLProvider + description: RelationalDatabaseProviderStatus defines the observed state + of RelationalDatabaseProvider properties: conditions: description: Conditions defines the status conditions @@ -187,41 +198,42 @@ spec: - type type: object type: array - mysqlConnectionStatus: - description: MySQLConnectionStatus provides the status of the MySQL + connectionStatus: + description: ConnectionStatus provides the status of the relational database items: - description: MySQLConnectionStatus defines the status of a MySQL + description: ConnectionStatus defines the status of a relational database connection properties: + databaseVersion: + description: DatabaseVersion is the version of the relational + database + type: string enabled: description: Enabled is a flag to indicate whether a MySQL database is enabled or not type: boolean hostname: - description: Hostname is the hostname of the MySQL database - type: string - mysqlVersion: - description: MySQLVersion is the version of the MySQL database + description: Hostname is the hostname of the relational database type: string name: description: |- - Name is the name of the MySQL database connection + Name is the name of the relational database connection it is used to identify the connection. Please use a unique name for each connection. This field will be used in the DatabaseRequest - to reference the connection. The databasemysqlprovider controller will + to reference the connection. The relationaldatabaseprovider controller will error if the name is not unique. type: string status: - description: Status is the status of the MySQL database + description: Status is the status of the relational database enum: - available - unavailable type: string required: + - databaseVersion - enabled - hostname - - mysqlVersion - name - status type: object diff --git a/config/crd/kustomization.yaml b/config/crd/kustomization.yaml index 4a952b0..b4777dc 100644 --- a/config/crd/kustomization.yaml +++ b/config/crd/kustomization.yaml @@ -3,20 +3,20 @@ # It should be run by config/default resources: - bases/crd.lagoon.sh_databaserequests.yaml -- bases/crd.lagoon.sh_databasemysqlproviders.yaml +- bases/crd.lagoon.sh_relationaldatabaseproviders.yaml #+kubebuilder:scaffold:crdkustomizeresource patches: # [WEBHOOK] To enable webhook, uncomment all the sections with [WEBHOOK] prefix. # patches here are for enabling the conversion webhook for each CRD #- path: patches/webhook_in_databaserequests.yaml -#- path: patches/webhook_in_databasemysqlproviders.yaml +#- path: patches/webhook_in_relationaldatabaseproviders.yaml #+kubebuilder:scaffold:crdkustomizewebhookpatch # [CERTMANAGER] To enable cert-manager, uncomment all the sections with [CERTMANAGER] prefix. # patches here are for enabling the CA injection for each CRD #- path: patches/cainjection_in_databaserequests.yaml -#- path: patches/cainjection_in_databasemysqlproviders.yaml +#- path: patches/cainjection_in_relationaldatabaseproviders.yaml #+kubebuilder:scaffold:crdkustomizecainjectionpatch # [WEBHOOK] To enable webhook, uncomment the following section diff --git a/config/rbac/databasemysqlprovider_editor_role.yaml b/config/rbac/relationaldatabaseprovider_editor_role.yaml similarity index 65% rename from config/rbac/databasemysqlprovider_editor_role.yaml rename to config/rbac/relationaldatabaseprovider_editor_role.yaml index 9aea4e5..72fc902 100644 --- a/config/rbac/databasemysqlprovider_editor_role.yaml +++ b/config/rbac/relationaldatabaseprovider_editor_role.yaml @@ -1,20 +1,20 @@ -# permissions for end users to edit databasemysqlproviders. +# permissions for end users to edit relationaldatabaseproviders. apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRole metadata: labels: app.kubernetes.io/name: clusterrole - app.kubernetes.io/instance: databasemysqlprovider-editor-role + app.kubernetes.io/instance: relationaldatabaseprovider-editor-role app.kubernetes.io/component: rbac app.kubernetes.io/created-by: dbaas-controller app.kubernetes.io/part-of: dbaas-controller app.kubernetes.io/managed-by: kustomize - name: databasemysqlprovider-editor-role + name: relationaldatabaseprovider-editor-role rules: - apiGroups: - crd.lagoon.sh resources: - - databasemysqlproviders + - relationaldatabaseproviders verbs: - create - delete @@ -26,6 +26,6 @@ rules: - apiGroups: - crd.lagoon.sh resources: - - databasemysqlproviders/status + - relationaldatabaseproviders/status verbs: - get diff --git a/config/rbac/databasemysqlprovider_viewer_role.yaml b/config/rbac/relationaldatabaseprovider_viewer_role.yaml similarity index 63% rename from config/rbac/databasemysqlprovider_viewer_role.yaml rename to config/rbac/relationaldatabaseprovider_viewer_role.yaml index 477005c..90a6428 100644 --- a/config/rbac/databasemysqlprovider_viewer_role.yaml +++ b/config/rbac/relationaldatabaseprovider_viewer_role.yaml @@ -1,20 +1,20 @@ -# permissions for end users to view databasemysqlproviders. +# permissions for end users to view relationaldatabaseproviders. apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRole metadata: labels: app.kubernetes.io/name: clusterrole - app.kubernetes.io/instance: databasemysqlprovider-viewer-role + app.kubernetes.io/instance: relationaldatabaseprovider-viewer-role app.kubernetes.io/component: rbac app.kubernetes.io/created-by: dbaas-controller app.kubernetes.io/part-of: dbaas-controller app.kubernetes.io/managed-by: kustomize - name: databasemysqlprovider-viewer-role + name: relationaldatabaseprovider-viewer-role rules: - apiGroups: - crd.lagoon.sh resources: - - databasemysqlproviders + - relationaldatabaseproviders verbs: - get - list @@ -22,6 +22,6 @@ rules: - apiGroups: - crd.lagoon.sh resources: - - databasemysqlproviders/status + - relationaldatabaseproviders/status verbs: - get diff --git a/config/rbac/role.yaml b/config/rbac/role.yaml index 4226aeb..dee6411 100644 --- a/config/rbac/role.yaml +++ b/config/rbac/role.yaml @@ -38,7 +38,7 @@ rules: - apiGroups: - crd.lagoon.sh resources: - - databasemysqlproviders + - databaserequests verbs: - create - delete @@ -50,13 +50,13 @@ rules: - apiGroups: - crd.lagoon.sh resources: - - databasemysqlproviders/finalizers + - databaserequests/finalizers verbs: - update - apiGroups: - crd.lagoon.sh resources: - - databasemysqlproviders/status + - databaserequests/status verbs: - get - patch @@ -64,7 +64,7 @@ rules: - apiGroups: - crd.lagoon.sh resources: - - databaserequests + - relationaldatabaseproviders verbs: - create - delete @@ -76,13 +76,13 @@ rules: - apiGroups: - crd.lagoon.sh resources: - - databaserequests/finalizers + - relationaldatabaseproviders/finalizers verbs: - update - apiGroups: - crd.lagoon.sh resources: - - databaserequests/status + - relationaldatabaseproviders/status verbs: - get - patch diff --git a/config/samples/crd_v1alpha1_databaserequest_mysql.yaml b/config/samples/crd_v1alpha1_databaserequest_mysql.yaml new file mode 100644 index 0000000..320ca0c --- /dev/null +++ b/config/samples/crd_v1alpha1_databaserequest_mysql.yaml @@ -0,0 +1,14 @@ +apiVersion: crd.lagoon.sh/v1alpha1 +kind: DatabaseRequest +metadata: + labels: + app.kubernetes.io/name: databaserequest + app.kubernetes.io/instance: databaserequest-mysql-sample + app.kubernetes.io/part-of: dbaas-controller + app.kubernetes.io/managed-by: kustomize + app.kubernetes.io/created-by: dbaas-controller + name: databaserequest-mysql-sample +spec: + name: first-mysql-db + scope: development + type: mysql \ No newline at end of file diff --git a/config/samples/crd_v1alpha1_databaserequest.yaml b/config/samples/crd_v1alpha1_databaserequest_with_seed.yaml similarity index 82% rename from config/samples/crd_v1alpha1_databaserequest.yaml rename to config/samples/crd_v1alpha1_databaserequest_with_seed.yaml index 5ad53f6..dd16a53 100644 --- a/config/samples/crd_v1alpha1_databaserequest.yaml +++ b/config/samples/crd_v1alpha1_databaserequest_with_seed.yaml @@ -9,6 +9,9 @@ metadata: app.kubernetes.io/created-by: dbaas-controller name: databaserequest-sample spec: - name: first-mysql-db + name: seed-mysql-db + seed: + name: seed-mysql-secret + namespace: default scope: development type: mysql \ No newline at end of file diff --git a/config/samples/crd_v1alpha1_databasemysqlprovider.yaml b/config/samples/crd_v1alpha1_relationaldatabaseprovider_mysql.yaml similarity index 50% rename from config/samples/crd_v1alpha1_databasemysqlprovider.yaml rename to config/samples/crd_v1alpha1_relationaldatabaseprovider_mysql.yaml index f697514..a2a9167 100644 --- a/config/samples/crd_v1alpha1_databasemysqlprovider.yaml +++ b/config/samples/crd_v1alpha1_relationaldatabaseprovider_mysql.yaml @@ -1,16 +1,14 @@ apiVersion: crd.lagoon.sh/v1alpha1 -kind: DatabaseMySQLProvider +kind: RelationalDatabaseProvider metadata: labels: - app.kubernetes.io/name: databasemysqlprovider - app.kubernetes.io/instance: databasemysqlprovider-sample - app.kubernetes.io/part-of: dbaas-controller + app.kubernetes.io/name: dbaas-controller app.kubernetes.io/managed-by: kustomize - app.kubernetes.io/created-by: dbaas-controller - name: databasemysqlprovider-sample + name: relationaldatabaseprovider-mysql-sample spec: + kind: mysql scope: development - mysqlConnections: + connections: - name: primary-test-mysql-connection hostname: mysql-service.mysql passwordSecretRef: @@ -18,4 +16,4 @@ spec: namespace: mysql port: 3306 username: root - enabled: true \ No newline at end of file + enabled: true diff --git a/config/samples/kustomization.yaml b/config/samples/kustomization.yaml index ed45112..c77019b 100644 --- a/config/samples/kustomization.yaml +++ b/config/samples/kustomization.yaml @@ -1,5 +1,5 @@ ## Append samples of your project ## resources: - crd_v1alpha1_databaserequest.yaml -- crd_v1alpha1_databasemysqlprovider.yaml +- crd_v1alpha1_relationaldatabaseprovider.yaml #+kubebuilder:scaffold:manifestskustomizesamples diff --git a/go.mod b/go.mod index 5c1712b..b533bec 100644 --- a/go.mod +++ b/go.mod @@ -39,6 +39,7 @@ require ( github.com/imdario/mergo v0.3.6 // indirect github.com/josharian/intern v1.0.0 // indirect github.com/json-iterator/go v1.1.12 // indirect + github.com/lib/pq v1.10.9 github.com/mailru/easyjson v0.7.7 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect diff --git a/go.sum b/go.sum index ba91ab9..653af10 100644 --- a/go.sum +++ b/go.sum @@ -71,6 +71,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/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= +github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= diff --git a/internal/controller/databasemysqlprovider_controller.go b/internal/controller/databasemysqlprovider_controller.go deleted file mode 100644 index 0d1d4ec..0000000 --- a/internal/controller/databasemysqlprovider_controller.go +++ /dev/null @@ -1,344 +0,0 @@ -/* -Copyright 2024. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -package controller - -import ( - "context" - "fmt" - - v1 "k8s.io/api/core/v1" - "k8s.io/apimachinery/pkg/api/meta" - 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" - "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" - "sigs.k8s.io/controller-runtime/pkg/log" - "sigs.k8s.io/controller-runtime/pkg/metrics" - "sigs.k8s.io/controller-runtime/pkg/predicate" - - "github.com/prometheus/client_golang/prometheus" - crdv1alpha1 "github.com/uselagoon/dbaas-controller/api/v1alpha1" - "github.com/uselagoon/dbaas-controller/internal/database/mysql" -) - -const databaseMySQLProviderFinalizer = "databasemysqlprovider.crd.lagoon.sh/finalizer" - -var ( - // Prometheus metrics - // promDatabaseMySQLProviderReconcileCounter is the counter for the reconciled database mysql providers - promDatabaseMySQLProviderReconcileCounter = prometheus.NewCounterVec( - prometheus.CounterOpts{ - Name: "databasemysqlprovider_reconcile_total", - Help: "The total number of reconciled database mysql providers", - }, - []string{"name"}, - ) - - //promDatabaseMySQLProviderReconcileErrorCounter is the counter for the reconciled database mysql providers errors - promDatabaseMySQLProviderReconcileErrorCounter = prometheus.NewCounterVec( - prometheus.CounterOpts{ - Name: "databasemysqlprovider_reconcile_error_total", - Help: "The total number of reconciled database mysql providers errors", - }, - []string{"name", "scope", "error"}, - ) - - // promDatabaseMySQLProviderStatus is the gauge for the database mysql provider status - promDatabaseMySQLProviderStatus = prometheus.NewGaugeVec( - prometheus.GaugeOpts{ - Name: "databasemysqlprovider_status", - Help: "The status of the database mysql provider", - }, - []string{"name", "scope"}, - ) - - // promDatabaseMySQLProviderConnectionVersion is the gauge for the database mysql provider connection version - promDatabaseMySQLProviderConnectionVersion = prometheus.NewGaugeVec( - prometheus.GaugeOpts{ - Name: "databasemysqlprovider_connection_version", - Help: "The version of the database mysql provider connection", - }, - []string{"name", "scope", "hostname", "username", "version"}, - ) -) - -// DatabaseMySQLProviderReconciler reconciles a DatabaseMySQLProvider object -type DatabaseMySQLProviderReconciler struct { - client.Client - Scheme *runtime.Scheme - Recorder record.EventRecorder - MySQLClient mysql.MySQLInterface -} - -//+kubebuilder:rbac:groups=core,resources=events,verbs=create;patch -//+kubebuilder:rbac:groups=crd.lagoon.sh,resources=databasemysqlproviders,verbs=get;list;watch;create;update;patch;delete -//+kubebuilder:rbac:groups=crd.lagoon.sh,resources=databasemysqlproviders/status,verbs=get;update;patch -//+kubebuilder:rbac:groups=crd.lagoon.sh,resources=databasemysqlproviders/finalizers,verbs=update -//+kubebuilder:rbac:groups=core,resources=secrets,verbs=get;list;watch;create;update;patch;delete - -// Reconcile is part of the main kubernetes reconciliation loop which aims to -// move the current state of the cluster closer to the desired state. -func (r *DatabaseMySQLProviderReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { - logger := log.FromContext(ctx).WithName("databasemysqlprovider_controller") - logger.Info("Reconciling DatabaseMySQLProvider") - promDatabaseMySQLProviderReconcileCounter.WithLabelValues(req.Name).Inc() - - // Fetch the DatabaseMySQLProvider instance - instance := &crdv1alpha1.DatabaseMySQLProvider{} - if err := r.Get(ctx, req.NamespacedName, instance); err != nil { - if client.IgnoreNotFound(err) == nil { - logger.Info("DatabaseMySQLProvider not found") - return ctrl.Result{}, nil - } - promDatabaseMySQLProviderReconcileErrorCounter.WithLabelValues(req.Name, "", "get-dbmysqlprovider").Inc() - return ctrl.Result{}, err - } - - if instance.DeletionTimestamp != nil && !instance.DeletionTimestamp.IsZero() { - // The object is being deleted - // To be discussed whether we need to delete all the database requests using this provider... - if controllerutil.RemoveFinalizer(instance, databaseMySQLProviderFinalizer) { - if err := r.Update(ctx, instance); err != nil { - return r.handleError(ctx, instance, "remove-finalizer", err) - } - } - return ctrl.Result{}, nil - } - - // Check if we need to reconcile based on Generation and ObservedGeneration but only if - // the status condition is not false. This makes sure that in case of an error the controller - // will try to reconcile again. - if instance.Status.Conditions != nil && meta.IsStatusConditionTrue(instance.Status.Conditions, "Ready") { - if instance.Status.ObservedGeneration >= instance.Generation { - logger.Info("No updates to reconcile") - r.Recorder.Event(instance, v1.EventTypeNormal, "ReconcileSkipped", "No updates to reconcile") - return ctrl.Result{}, nil - } - } - - if controllerutil.AddFinalizer(instance, databaseMySQLProviderFinalizer) { - if err := r.Update(ctx, instance); err != nil { - return r.handleError(ctx, instance, "add-finalizer", err) - } - } - - // Reconcile the DatabaseMySQLProvider and check the unique name of the MySQLConnections - uniqueNames := make(map[string]struct{}, len(instance.Spec.MySQLConnections)) - mySQLConns := make([]mySQLConn, 0, len(instance.Spec.MySQLConnections)) - for _, conn := range instance.Spec.MySQLConnections { - secret := &v1.Secret{} - if err := r.Get(ctx, types.NamespacedName{ - Name: conn.PasswordSecretRef.Name, - Namespace: conn.PasswordSecretRef.Namespace, - }, secret); err != nil { - return r.handleError(ctx, instance, "get-secret", err) - } - password := string(secret.Data["password"]) - if password == "" { - return r.handleError( - ctx, - instance, - "empty-password", - fmt.Errorf("mysql connection secret %s in namespace %s has empty password", secret.Name, secret.Namespace), - ) - } - mySQLConns = append(mySQLConns, mySQLConn{ - name: conn.Name, - hostname: conn.Hostname, - replicaHostnames: conn.ReplicaHostnames, - password: password, - port: conn.Port, - username: conn.Username, - enabled: conn.Enabled, - }) - uniqueNames[conn.Hostname] = struct{}{} - } - - if len(uniqueNames) != len(instance.Spec.MySQLConnections) { - return r.handleError( - ctx, - instance, - "unique-name", - fmt.Errorf("mysql connections must have unique names"), - ) - } - - mySQLStatus := make([]crdv1alpha1.MySQLConnectionStatus, 0, len(mySQLConns)) - errors := make([]error, 0, len(mySQLConns)) - foundEnabledMySQL := false - for _, conn := range mySQLConns { - // make a ping to the database to check if it's up and running and we can connect to it - // if not, we should return an error and set the status to 0 - // Note we could periodically check the status of the database and update the status accordingly... - if err := r.MySQLClient.Ping(ctx, conn.getDSN()); err != nil { - errors = append(errors, err) - mySQLStatus = append(mySQLStatus, crdv1alpha1.MySQLConnectionStatus{ - Name: conn.name, - Hostname: conn.hostname, - Status: "unavailable", - Enabled: conn.enabled, - }) - continue - } - version, err := r.MySQLClient.Version(ctx, conn.getDSN()) - if err != nil { - errors = append(errors, err) - mySQLStatus = append(mySQLStatus, crdv1alpha1.MySQLConnectionStatus{ - Name: conn.name, - Hostname: conn.hostname, - Status: "unavailable", - Enabled: conn.enabled, - }) - continue - } - - // check if the database is initialized - err = r.MySQLClient.Initialize(ctx, conn.getDSN()) - if err != nil { - errors = append(errors, err) - mySQLStatus = append(mySQLStatus, crdv1alpha1.MySQLConnectionStatus{ - Name: conn.name, - Hostname: conn.hostname, - Status: "unavailable", - Enabled: conn.enabled, - }) - continue - } - - promDatabaseMySQLProviderConnectionVersion.WithLabelValues( - req.Name, instance.Spec.Scope, conn.hostname, conn.username, version).Set(1) - mySQLStatus = append(mySQLStatus, crdv1alpha1.MySQLConnectionStatus{ - Name: conn.name, - Hostname: conn.hostname, - MySQLVersion: version, - Status: "available", - Enabled: conn.enabled, - }) - - if conn.enabled { - foundEnabledMySQL = true - } - } - - instance.Status.MySQLConnectionStatus = mySQLStatus - instance.Status.ObservedGeneration = instance.Generation - - if len(errors) == len(mySQLConns) { - return r.handleError( - ctx, - instance, - "mysql-connection", - fmt.Errorf("failed to connect to any of the MySQL databases: %v", errors), - ) - } - if !foundEnabledMySQL { - return r.handleError( - ctx, - instance, - "mysql-connection", - fmt.Errorf("no enabled working MySQL database found"), - ) - } - - // update the status condition to ready - meta.SetStatusCondition(&instance.Status.Conditions, metav1.Condition{ - Type: "Ready", - Status: metav1.ConditionTrue, - Reason: "Reconciled", - Message: "DatabaseMySQLProvider reconciled", - }) - // update the status - if err := r.Status().Update(ctx, instance); err != nil { - promDatabaseMySQLProviderReconcileErrorCounter.WithLabelValues( - req.Name, instance.Spec.Scope, "update-status").Inc() - promDatabaseMySQLProviderStatus.WithLabelValues(req.Name, instance.Spec.Scope).Set(0) - return ctrl.Result{}, err - } - - r.Recorder.Event(instance, "Normal", "Reconciled", "DatabaseMySQLProvider reconciled") - promDatabaseMySQLProviderStatus.WithLabelValues(req.Name, instance.Spec.Scope).Set(1) - return ctrl.Result{}, nil -} - -// handleError handles the error and returns the result and the error -func (r *DatabaseMySQLProviderReconciler) handleError( - ctx context.Context, - instance *crdv1alpha1.DatabaseMySQLProvider, - promErr string, - err error, -) (ctrl.Result, error) { - promDatabaseMySQLProviderReconcileErrorCounter.WithLabelValues( - instance.Name, instance.Spec.Scope, promErr).Inc() - promDatabaseMySQLProviderStatus.WithLabelValues(instance.Name, instance.Spec.Scope).Set(0) - r.Recorder.Event(instance, v1.EventTypeWarning, errTypeToEventReason(promErr), err.Error()) - - // set the status condition to false - meta.SetStatusCondition(&instance.Status.Conditions, metav1.Condition{ - Type: "Ready", - Status: metav1.ConditionFalse, - Reason: errTypeToEventReason(promErr), - Message: err.Error(), - }) - - // update the status - if err := r.Status().Update(ctx, instance); err != nil { - promDatabaseMySQLProviderReconcileErrorCounter.WithLabelValues( - instance.Name, instance.Spec.Scope, "update-status").Inc() - log.FromContext(ctx).Error(err, "Failed to update status") - } - - return ctrl.Result{}, err -} - -// mysqlConn is the connection to a MySQL database -type mySQLConn struct { - name string - hostname string - replicaHostnames []string - password string - port int - username string - enabled bool -} - -// getDSN constructs the DSN string for the MySQL connection. -func (mc *mySQLConn) getDSN() string { - return fmt.Sprintf("%s:%s@tcp(%s:%d)/", mc.username, mc.password, mc.hostname, mc.port) -} - -// SetupWithManager sets up the controller with the Manager. -func (r *DatabaseMySQLProviderReconciler) SetupWithManager(mgr ctrl.Manager) error { - // register metrics - metrics.Registry.MustRegister( - promDatabaseMySQLProviderReconcileCounter, - promDatabaseMySQLProviderReconcileErrorCounter, - promDatabaseMySQLProviderStatus, - promDatabaseMySQLProviderConnectionVersion, - ) - r.Recorder = mgr.GetEventRecorderFor("databasemysqlprovider_controller") - return ctrl.NewControllerManagedBy(mgr). - For(&crdv1alpha1.DatabaseMySQLProvider{}). - WithEventFilter(predicate.GenerationChangedPredicate{}). - // let's set the max concurrent reconciles to 1 as we don't want to run multiple reconciles at the same time - // although we could also change this and guard it by the name of the database provider - WithOptions(controller.Options{MaxConcurrentReconciles: 1}). - Complete(r) -} diff --git a/internal/controller/databaserequest_controller.go b/internal/controller/databaserequest_controller.go index 02c42c9..9b3f2bc 100644 --- a/internal/controller/databaserequest_controller.go +++ b/internal/controller/databaserequest_controller.go @@ -41,7 +41,7 @@ import ( "github.com/google/go-cmp/cmp" "github.com/prometheus/client_golang/prometheus" crdv1alpha1 "github.com/uselagoon/dbaas-controller/api/v1alpha1" - "github.com/uselagoon/dbaas-controller/internal/database/mysql" + "github.com/uselagoon/dbaas-controller/internal/database" ) const databaseRequestFinalizer = "databaserequest.crd.lagoon.sh/finalizer" @@ -82,10 +82,10 @@ var ( // DatabaseRequestReconciler reconciles a DatabaseRequest object type DatabaseRequestReconciler struct { client.Client - Scheme *runtime.Scheme - Recorder record.EventRecorder - MySQLClient mysql.MySQLInterface - Locks sync.Map + Scheme *runtime.Scheme + Recorder record.EventRecorder + RelationalDatabaseClient database.RelationalDatabaseInterface + Locks sync.Map } const ( @@ -173,19 +173,18 @@ func (r *DatabaseRequestReconciler) Reconcile(ctx context.Context, req ctrl.Requ // Implementing additional users would require to extend the logic here // check if the database request is already created and the secret and service exist var dbInfo dbInfo - switch databaseRequest.Spec.Type { - case mysqlType: - logger.Info("Get MySQL database information") + if databaseRequest.Spec.Type == mysqlType || databaseRequest.Spec.Type == postgresType { + logger.Info("Get relational database info") + // get the database info var err error - dbInfo, err = r.mysqlInfo(ctx, databaseRequest) + dbInfo, err = r.relDBInfo(ctx, databaseRequest) if err != nil { - return r.handleError(ctx, databaseRequest, "mysql-info", err) + return r.handleError( + ctx, databaseRequest, fmt.Sprintf("get-%s-database-info", databaseRequest.Spec.Type), err) } - case postgresType: - logger.Info("Get PostgreSQL database information") - case mongodbType: - logger.Info("Get MongoDB database information") - default: + } else if databaseRequest.Spec.Type == mongodbType { + logger.Info("Get mongodb database info") + } else { logger.Error(ErrInvalidDatabaseType, "Unsupported database type", "type", databaseRequest.Spec.Type) } @@ -367,22 +366,18 @@ func (r *DatabaseRequestReconciler) deleteDatabase( // handle deletion logic logger := log.FromContext(ctx) if databaseRequest.Spec.DropDatabaseOnDelete { - switch databaseRequest.Spec.Type { - case mysqlType: - // handle mysql deletion + if databaseRequest.Spec.Type == mysqlType || databaseRequest.Spec.Type == postgresType { + // handle relational database deletion // Note at the moment we only have one "primary" connection per database request // Implementing additional users would require to extend the logic here - logger.Info("Dropping MySQL database") - if err := r.mysqlDeletion(ctx, databaseRequest); err != nil { - return r.handleError(ctx, databaseRequest, "mysql-drop", err) + logger.Info("Dropping relational database") + if err := r.relDBDeletion(ctx, databaseRequest); err != nil { + return r.handleError(ctx, databaseRequest, fmt.Sprintf("%s-drop", databaseRequest.Spec.Type), err) } - case postgresType: - // handle postgres deletion - logger.Info("Dropping PostgreSQL database") - case mongodbType: + } else if databaseRequest.Spec.Type == mongodbType { // handle mongodb deletion logger.Info("Dropping MongoDB database") - default: + } else { // this should never happen, but just in case logger.Error(ErrInvalidDatabaseType, "Unsupported database type", "type", databaseRequest.Spec.Type) return r.handleError(ctx, databaseRequest, "invalid-database-type", ErrInvalidDatabaseType) @@ -421,28 +416,23 @@ func (r *DatabaseRequestReconciler) deleteDatabase( func (r *DatabaseRequestReconciler) createDatabase( ctx context.Context, databaseRequest *crdv1alpha1.DatabaseRequest) error { logger := log.FromContext(ctx) - switch databaseRequest.Spec.Type { - case mysqlType: - // handle mysql creation + if databaseRequest.Spec.Type == mysqlType || databaseRequest.Spec.Type == postgresType { + // handle relational database creation // Note at the moment we only have one "primary" connection per database request // Implementing additional users would require to extend the logic here - logger.Info("Creating MySQL database") - if err := r.mysqlOperation(ctx, create, databaseRequest, nil); err != nil { - return fmt.Errorf("mysql db creation failed: %w", err) + logger.Info("Creating relational database") + if err := r.relationalDatabaseOperation(ctx, create, databaseRequest, nil); err != nil { + return fmt.Errorf("%s db creation failed: %w", databaseRequest.Spec.Type, err) } if databaseRequest.Spec.DatabaseConnectionReference == nil { - return fmt.Errorf("mysql db creation failed due to missing database connection reference") + return fmt.Errorf("%s db creation failed due to missing database connection reference", databaseRequest.Spec.Type) } if databaseRequest.Status.DatabaseInfo == nil { - return fmt.Errorf("mysql db creation failed due to missing database info") + return fmt.Errorf("%s db creation failed due to missing database info", databaseRequest.Spec.Type) } - case postgresType: - // handle postgres creation - logger.Info("Creating PostgreSQL database") - case mongodbType: - // handle mongodb creation + } else if databaseRequest.Spec.Type == mongodbType { logger.Info("Creating MongoDB database") - default: + } else { // this should never happen, but just in case logger.Error(ErrInvalidDatabaseType, "Unsupported database type", "type", databaseRequest.Spec.Type) return fmt.Errorf("failed to create database: %w", ErrInvalidDatabaseType) @@ -517,51 +507,56 @@ func (m *dbInfo) getSecretData(name, serviceName string) map[string][]byte { } } -// mysqlOperation performs the MySQL operations create and drop -func (r *DatabaseRequestReconciler) mysqlOperation( +// relationalDatabaseOperation performs the relational database operations to create, drop and get database information +func (r *DatabaseRequestReconciler) relationalDatabaseOperation( ctx context.Context, operation string, databaseRequest *crdv1alpha1.DatabaseRequest, databaseInfo *dbInfo, ) error { - log.FromContext(ctx).Info("Performing MySQL operation", "operation", operation) + log.FromContext(ctx).Info("Performing relational database operation", "operation", operation) // get the database provider, for info and drop we use the reference which is already set on the database request // if not we error out. // For create we list all database providers and check if the scope matches and if // there are more than one provider with the same scope, we select the one with lower load. - databaseProvider := &crdv1alpha1.DatabaseMySQLProvider{} + databaseProvider := &crdv1alpha1.RelationalDatabaseProvider{} connectionName := "" if operation == create { var err error - databaseProvider, connectionName, err = r.findMySQLProvider(ctx, databaseRequest) + databaseProvider, connectionName, err = r.findRelationalDatabaseProvider(ctx, databaseRequest) if err != nil { - return fmt.Errorf("mysql db operation %s failed to find database provider: %w", operation, err) + return fmt.Errorf( + "%s db operation %s failed to find database provider: %w", databaseRequest.Spec.Type, operation, err) } - log.FromContext(ctx).Info("Found MySQL provider", "provider", databaseProvider.Name, "connection", connectionName) + log.FromContext(ctx).Info( + "Found relational database provider", "provider", databaseProvider.Name, "connection", connectionName) } else { if databaseRequest.Spec.DatabaseConnectionReference == nil { - return fmt.Errorf("mysql db operation %s failed due to missing database connection reference", operation) + return fmt.Errorf( + "%s db operation %s failed due to missing database connection reference", databaseRequest.Spec.Type, operation) } if err := r.Get(ctx, client.ObjectKey{ Name: databaseRequest.Spec.DatabaseConnectionReference.DatabaseObjectReference.Name, }, databaseProvider); err != nil { - return fmt.Errorf("mysql db operation %s failed to get database provider: %w", operation, err) + return fmt.Errorf( + "%s db operation %s failed to get database provider: %w", databaseRequest.Spec.Type, operation, err) } connectionName = databaseRequest.Spec.DatabaseConnectionReference.Name - log.FromContext(ctx).Info("Found MySQL provider", "provider", databaseProvider.Name, "connection", connectionName) + log.FromContext(ctx).Info( + "Found relational database provider", "provider", databaseProvider.Name, "connection", connectionName) } - var connection *crdv1alpha1.MySQLConnection - for _, c := range databaseProvider.Spec.MySQLConnections { - log.FromContext(ctx).Info("Checking MySQL provider database connection", "connection", c.Name) + var connection *crdv1alpha1.Connection + for _, c := range databaseProvider.Spec.Connections { + log.FromContext(ctx).Info("Checking relational database provider database connection", "connection", c.Name) if c.Name == connectionName { conn := c // Create a new variable and assign the value of c to it connection = &conn // Assign the address of the new variable to connection } } if connection == nil { - return fmt.Errorf("mysql db operation %s failed to find database connection", operation) + return fmt.Errorf("%s db operation %s failed to find database connection", databaseRequest.Spec.Type, operation) } secret := &v1.Secret{} @@ -569,15 +564,17 @@ func (r *DatabaseRequestReconciler) mysqlOperation( Name: connection.PasswordSecretRef.Name, Namespace: connection.PasswordSecretRef.Namespace, }, secret); err != nil { - return fmt.Errorf("mysql db operation %s failed to get connection password from secret: %w", operation, err) + return fmt.Errorf( + "%s db operation %s failed to get connection password from secret: %w", databaseRequest.Spec.Type, operation, err) } password := string(secret.Data["password"]) if password == "" { - return fmt.Errorf("mysql db operation %s failed due to empty password", operation) + return fmt.Errorf("%s db operation %s failed due to empty password", databaseRequest.Spec.Type, operation) } - conn := mySQLConn{ + conn := reldbConn{ + kind: databaseRequest.Spec.Type, name: connection.Name, hostname: connection.Hostname, username: connection.Username, @@ -587,15 +584,16 @@ func (r *DatabaseRequestReconciler) mysqlOperation( switch operation { case create: - log.FromContext(ctx).Info("Creating MySQL database", "database", databaseRequest.Name) - info, err := r.MySQLClient.CreateDatabase( + log.FromContext(ctx).Info("Creating relational database", "database", databaseRequest.Name) + info, err := r.RelationalDatabaseClient.CreateDatabase( ctx, conn.getDSN(), databaseRequest.Name, databaseRequest.Namespace, + databaseRequest.Spec.Type, ) if err != nil { - return fmt.Errorf("mysql db operation %s failed: %w", operation, err) + return fmt.Errorf("%s db operation %s failed: %w", databaseRequest.Spec.Type, operation, err) } dbRef := &crdv1alpha1.DatabaseConnectionReference{ Name: connection.Name, @@ -612,39 +610,44 @@ func (r *DatabaseRequestReconciler) mysqlOperation( Databasename: info.Dbname, } if err := r.Status().Update(ctx, databaseRequest); err != nil { - return fmt.Errorf("mysql db operation %s failed to update database request: %w", operation, err) + return fmt.Errorf( + "%s db operation %s failed to update database request: %w", databaseRequest.Spec.Type, operation, err) } databaseRequest.Spec.DatabaseConnectionReference = dbRef return nil case drop: - if err := r.MySQLClient.DropDatabase( + if err := r.RelationalDatabaseClient.DropDatabase( ctx, conn.getDSN(), databaseRequest.Name, databaseRequest.Namespace, + databaseRequest.Spec.Type, ); err != nil { - return fmt.Errorf("mysql db opration %s failed: %w", operation, err) + return fmt.Errorf("%s db opration %s failed: %w", databaseRequest.Spec.Type, operation, err) } databaseRequest.Status.ObservedDatabaseConnectionReference = nil if err := r.Status().Update(ctx, databaseRequest); err != nil { - return fmt.Errorf("mysql db operation %s failed to update database request: %w", operation, err) + return fmt.Errorf( + "%s db operation %s failed to update database request: %w", databaseRequest.Spec.Type, operation, err) } databaseRequest.Spec.DatabaseConnectionReference = nil return nil case info: // check if the dbInfo is not nil if databaseInfo == nil { - return fmt.Errorf("mysql db operation %s failed due to missing dbInfo", operation) + return fmt.Errorf("%s db operation %s failed due to missing dbInfo", databaseRequest.Spec.Type, operation) } // get the database information - info, err := r.MySQLClient.GetDatabase( + info, err := r.RelationalDatabaseClient.GetDatabase( ctx, conn.getDSN(), databaseRequest.Name, databaseRequest.Namespace, + databaseRequest.Spec.Type, ) if err != nil { - return fmt.Errorf("mysql db operation %s failed to get database information: %w", operation, err) + return fmt.Errorf( + "%s db operation %s failed to get database information: %w", databaseRequest.Spec.Type, operation, err) } databaseInfo.userName = info.Username databaseInfo.password = info.Password @@ -653,30 +656,32 @@ func (r *DatabaseRequestReconciler) mysqlOperation( databaseInfo.port = conn.port return nil default: - return fmt.Errorf("mysql db operation %s failed due to invalid operation", operation) + return fmt.Errorf("%s db operation %s failed due to invalid operation", databaseRequest.Spec.Type, operation) } } -// findMySQLProvider finds the MySQL provider with the same scope and the lower load +// findRelationalDatabaseProvider finds the relational database provider with the same scope and the lower load // returns the provider, connection name and an error -func (r *DatabaseRequestReconciler) findMySQLProvider( +func (r *DatabaseRequestReconciler) findRelationalDatabaseProvider( ctx context.Context, databaseRequest *crdv1alpha1.DatabaseRequest, -) (*crdv1alpha1.DatabaseMySQLProvider, string, error) { - dbProviders := &crdv1alpha1.DatabaseMySQLProviderList{} +) (*crdv1alpha1.RelationalDatabaseProvider, string, error) { + dbProviders := &crdv1alpha1.RelationalDatabaseProviderList{} if err := r.List(ctx, dbProviders); err != nil { - return nil, "", fmt.Errorf("mysql db find provider failed to list database providers: %w", err) + return nil, "", fmt.Errorf("%s db find provider failed to list database providers: %w", + databaseRequest.Spec.Type, err, + ) } // find the provider with the same scope // set load to the max int value to find the provider with the lower load := int(^uint(0) >> 1) - var provider *crdv1alpha1.DatabaseMySQLProvider + var provider *crdv1alpha1.RelationalDatabaseProvider var connName string for _, dbProvider := range dbProviders.Items { if dbProvider.Spec.Scope == databaseRequest.Spec.Scope { - log.FromContext(ctx).Info("Found MySQL provider", "provider", dbProvider.Name) - for _, dbConnection := range dbProvider.Spec.MySQLConnections { + log.FromContext(ctx).Info("Found provider", "provider", dbProvider.Name) + for _, dbConnection := range dbProvider.Spec.Connections { if dbConnection.Enabled { // fetch the password from the secret secret := &v1.Secret{} @@ -684,15 +689,18 @@ func (r *DatabaseRequestReconciler) findMySQLProvider( Name: dbConnection.PasswordSecretRef.Name, Namespace: dbConnection.PasswordSecretRef.Namespace, }, secret); err != nil { - return nil, "", fmt.Errorf("mysql db find provider failed to get connection password from secret: %w", err) + return nil, "", fmt.Errorf("%s db find provider failed to get connection password from secret: %w", + databaseRequest.Spec.Type, err, + ) } password := string(secret.Data["password"]) if password == "" { - return nil, "", errors.New("mysql db find provider failed due to empty password") + return nil, "", fmt.Errorf("%s db find provider failed due to empty password", databaseRequest.Spec.Type) } - conn := mySQLConn{ + conn := reldbConn{ + kind: databaseRequest.Spec.Type, name: dbConnection.Name, hostname: dbConnection.Hostname, username: dbConnection.Username, @@ -702,17 +710,17 @@ func (r *DatabaseRequestReconciler) findMySQLProvider( // check the load of the provider connection // we select the provider with the lower load - log.FromContext(ctx).Info("Checking MySQL provider database connection", "connection", dbConnection.Name) - dbLoad, err := r.MySQLClient.Load(ctx, conn.getDSN()) + log.FromContext(ctx).Info("Checking provider database connection", "connection", dbConnection.Name) + dbLoad, err := r.RelationalDatabaseClient.Load(ctx, conn.getDSN(), databaseRequest.Spec.Type) if err != nil { - return nil, "", fmt.Errorf("mysql db find provider failed to get load: %w", err) + return nil, "", fmt.Errorf("%s db find provider failed to get load: %w", databaseRequest.Spec.Type, err) } if dbLoad < load { p := dbProvider provider = &p connName = dbConnection.Name load = dbLoad - log.FromContext(ctx).Info("Found MySQL provider", "provider", + log.FromContext(ctx).Info("Found relational database provider", "provider", dbProvider.Name, "connection", dbConnection.Name, "load", dbLoad) } } @@ -720,36 +728,36 @@ func (r *DatabaseRequestReconciler) findMySQLProvider( } } if provider == nil { - return nil, "", errors.New("mysql db find provider failed due to provider not found") + return nil, "", fmt.Errorf("%s db find provider failed due to provider not found", databaseRequest.Spec.Type) } return provider, connName, nil } -// mysqlDeletion deletes the MySQL database -func (r *DatabaseRequestReconciler) mysqlDeletion( +// relDBDeletion deletes the relational database +func (r *DatabaseRequestReconciler) relDBDeletion( ctx context.Context, databaseRequest *crdv1alpha1.DatabaseRequest, ) error { - log.FromContext(ctx).Info("Deleting MySQL database") + log.FromContext(ctx).Info("Deleting relational database") // check the status to find the object reference to the database provider if databaseRequest.Spec.DatabaseConnectionReference == nil { // if there is no reference, we can't delete the database. - return errors.New("mysql db drop failed due to connection reference is missing") + return errors.New("relational db drop failed due to connection reference is missing") } - return r.mysqlOperation(ctx, drop, databaseRequest, nil) + return r.relationalDatabaseOperation(ctx, drop, databaseRequest, nil) } -// mysqlInfo retrieves the MySQL database information -func (r *DatabaseRequestReconciler) mysqlInfo( +// relDBInfo retrieves the relational database information +func (r *DatabaseRequestReconciler) relDBInfo( ctx context.Context, databaseRequest *crdv1alpha1.DatabaseRequest, ) (dbInfo, error) { - log.FromContext(ctx).Info("Retrieving MySQL database information") + log.FromContext(ctx).Info("Retrieving relational database information") dbInfo := dbInfo{} - if err := r.mysqlOperation(ctx, info, databaseRequest, &dbInfo); err != nil { - return dbInfo, fmt.Errorf("mysql db info failed: %w", err) + if err := r.relationalDatabaseOperation(ctx, info, databaseRequest, &dbInfo); err != nil { + return dbInfo, fmt.Errorf("relational db info failed: %w", err) } return dbInfo, nil } diff --git a/internal/controller/databaserequest_controller_test.go b/internal/controller/databaserequest_controller_test.go index 0d64151..f7a3c25 100644 --- a/internal/controller/databaserequest_controller_test.go +++ b/internal/controller/databaserequest_controller_test.go @@ -32,7 +32,7 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" crdv1alpha1 "github.com/uselagoon/dbaas-controller/api/v1alpha1" - "github.com/uselagoon/dbaas-controller/internal/database/mysql" + "github.com/uselagoon/dbaas-controller/internal/database" ) var _ = Describe("DatabaseRequest Controller", func() { @@ -45,15 +45,15 @@ var _ = Describe("DatabaseRequest Controller", func() { ctx := context.Background() databaserequest := &crdv1alpha1.DatabaseRequest{} - databaseMysqlProvider := &crdv1alpha1.DatabaseMySQLProvider{} - databaseMysqlProviderSecret := &v1.Secret{} + relationalDatabaseProvider := &crdv1alpha1.RelationalDatabaseProvider{} + relationalDatabaseProviderSecret := &v1.Secret{} BeforeEach(func() { - By("creating the custom resource for the Kind DatabaseMySQLProvider") + By("creating the custom resource for the Kind RelationalDatabaseProvider") err := k8sClient.Get(ctx, types.NamespacedName{ Name: dbMySQLProviderSecretResource, Namespace: "default", - }, databaseMysqlProviderSecret) + }, relationalDatabaseProviderSecret) if err != nil && errors.IsNotFound(err) { secret := &v1.Secret{ ObjectMeta: metav1.ObjectMeta{ @@ -68,15 +68,16 @@ var _ = Describe("DatabaseRequest Controller", func() { } err = k8sClient.Get(ctx, types.NamespacedName{ Name: dbMySQLProviderResource, - }, databaseMysqlProvider) + }, relationalDatabaseProvider) if err != nil && errors.IsNotFound(err) { - resource := &crdv1alpha1.DatabaseMySQLProvider{ + resource := &crdv1alpha1.RelationalDatabaseProvider{ ObjectMeta: metav1.ObjectMeta{ Name: dbMySQLProviderResource, }, - Spec: crdv1alpha1.DatabaseMySQLProviderSpec{ + Spec: crdv1alpha1.RelationalDatabaseProviderSpec{ + Kind: "mysql", Scope: "development", - MySQLConnections: []crdv1alpha1.MySQLConnection{ + Connections: []crdv1alpha1.Connection{ { Name: "test-connection", Hostname: "test-hostname", @@ -117,22 +118,22 @@ var _ = Describe("DatabaseRequest Controller", func() { }) AfterEach(func() { - By("Cleanup the specific resource instance DatabaseMySQLProvider") - databaseMysqlProvider = &crdv1alpha1.DatabaseMySQLProvider{} + By("Cleanup the specific resource instance RelationalDatabaseProvider") + relationalDatabaseProvider = &crdv1alpha1.RelationalDatabaseProvider{} err := k8sClient.Get(ctx, types.NamespacedName{ Name: dbMySQLProviderResource, - }, databaseMysqlProvider) + }, relationalDatabaseProvider) Expect(err).NotTo(HaveOccurred()) - Expect(k8sClient.Delete(ctx, databaseMysqlProvider)).To(Succeed()) + Expect(k8sClient.Delete(ctx, relationalDatabaseProvider)).To(Succeed()) By("Cleanup the specific resource instance Secret") - databaseMysqlProviderSecret = &v1.Secret{} + relationalDatabaseProviderSecret = &v1.Secret{} err = k8sClient.Get(ctx, types.NamespacedName{ Name: dbMySQLProviderSecretResource, Namespace: "default", - }, databaseMysqlProviderSecret) + }, relationalDatabaseProviderSecret) Expect(err).NotTo(HaveOccurred()) - Expect(k8sClient.Delete(ctx, databaseMysqlProviderSecret)).To(Succeed()) + Expect(k8sClient.Delete(ctx, relationalDatabaseProviderSecret)).To(Succeed()) By("Cleanup the specific resource instance DatabaseRequest") databaserequest := &crdv1alpha1.DatabaseRequest{} @@ -148,10 +149,10 @@ var _ = Describe("DatabaseRequest Controller", func() { By("Reconciling the created resource") fakeRecoder := record.NewFakeRecorder(100) controllerReconciler := &DatabaseRequestReconciler{ - Client: k8sClient, - Scheme: k8sClient.Scheme(), - Recorder: fakeRecoder, - MySQLClient: &mysql.MySQLMock{}, + Client: k8sClient, + Scheme: k8sClient.Scheme(), + Recorder: fakeRecoder, + RelationalDatabaseClient: &database.RelationalDatabaseMock{}, } _, err := controllerReconciler.Reconcile(ctx, reconcile.Request{ diff --git a/internal/controller/relationaldatabaseprovider_controller.go b/internal/controller/relationaldatabaseprovider_controller.go new file mode 100644 index 0000000..e35c4d7 --- /dev/null +++ b/internal/controller/relationaldatabaseprovider_controller.go @@ -0,0 +1,349 @@ +/* +Copyright 2024. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package controller + +import ( + "context" + "fmt" + + v1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/meta" + 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" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" + "sigs.k8s.io/controller-runtime/pkg/log" + "sigs.k8s.io/controller-runtime/pkg/metrics" + "sigs.k8s.io/controller-runtime/pkg/predicate" + + "github.com/prometheus/client_golang/prometheus" + crdv1alpha1 "github.com/uselagoon/dbaas-controller/api/v1alpha1" + "github.com/uselagoon/dbaas-controller/internal/database" +) + +const databaseProviderFinalizer = "relationaldatabaseprovider.crd.lagoon.sh/finalizer" + +var ( + // Prometheus metrics + // promRelationalDatabaseProviderReconcileErrorCounter counter for the reconciled relational database providers errors + promRelationalDatabaseProviderReconcileErrorCounter = prometheus.NewCounterVec( + prometheus.CounterOpts{ + Name: "relationaldatabaseprovider_reconcile_error_total", + Help: "The total number of reconciled relational database providers errors", + }, + []string{"kind", "name", "scope", "error"}, + ) + + // promRelationalDatabaseProviderStatus is the gauge for the relational database provider status + promRelationalDatabaseProviderStatus = prometheus.NewGaugeVec( + prometheus.GaugeOpts{ + Name: "relationaldatabaseprovider_status", + Help: "The status of the relational database provider", + }, + []string{"kind", "name", "scope"}, + ) + + // promRelationalDatabaseProviderConnectionVersion is the gauge for the relational database provider connection version + promRelationalDatabaseProviderConnectionVersion = prometheus.NewGaugeVec( + prometheus.GaugeOpts{ + Name: "relationaldatabaseprovider_connection_version", + Help: "The version of the relational database provider connection", + }, + []string{"kind", "name", "scope", "hostname", "username", "version"}, + ) +) + +// RelationalDatabaseProviderReconciler reconciles a RelationalDatabaseProvider object +type RelationalDatabaseProviderReconciler struct { + client.Client + Scheme *runtime.Scheme + Recorder record.EventRecorder + RelDBClient database.RelationalDatabaseInterface +} + +//+kubebuilder:rbac:groups=core,resources=events,verbs=create;patch +// nolint:lll +//+kubebuilder:rbac:groups=crd.lagoon.sh,resources=relationaldatabaseproviders,verbs=get;list;watch;create;update;patch;delete +//+kubebuilder:rbac:groups=crd.lagoon.sh,resources=relationaldatabaseproviders/status,verbs=get;update;patch +//+kubebuilder:rbac:groups=crd.lagoon.sh,resources=relationaldatabaseproviders/finalizers,verbs=update +//+kubebuilder:rbac:groups=core,resources=secrets,verbs=get;list;watch;create;update;patch;delete + +// Reconcile is part of the main kubernetes reconciliation loop which aims to +// move the current state of the cluster closer to the desired state. +func (r *RelationalDatabaseProviderReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { + logger := log.FromContext(ctx).WithName("relationaldatabaseprovider_controller") + logger.Info("Reconciling RelationalDatabaseProvider") + + // Fetch the RelationalDatabaseProvider instance + instance := &crdv1alpha1.RelationalDatabaseProvider{} + if err := r.Get(ctx, req.NamespacedName, instance); err != nil { + if client.IgnoreNotFound(err) == nil { + logger.Info("RelationalDatabaseProvider not found") + return ctrl.Result{}, nil + } + promRelationalDatabaseProviderReconcileErrorCounter.WithLabelValues( + "", req.Name, "", "get-relationaldbprovider").Inc() + return ctrl.Result{}, err + } + logger = logger.WithValues("kind", instance.Spec.Kind, "scope", instance.Spec.Scope) + if instance.DeletionTimestamp != nil && !instance.DeletionTimestamp.IsZero() { + // The object is being deleted + // To be discussed whether we need to delete all the database requests using this provider... + if controllerutil.RemoveFinalizer(instance, databaseProviderFinalizer) { + if err := r.Update(ctx, instance); err != nil { + return r.handleError(ctx, instance, "remove-finalizer", err) + } + } + return ctrl.Result{}, nil + } + + // Check if we need to reconcile based on Generation and ObservedGeneration but only if + // the status condition is not false. This makes sure that in case of an error the controller + // will try to reconcile again. + if instance.Status.Conditions != nil && meta.IsStatusConditionTrue(instance.Status.Conditions, "Ready") { + if instance.Status.ObservedGeneration >= instance.Generation { + logger.Info("No updates to reconcile") + r.Recorder.Event(instance, v1.EventTypeNormal, "ReconcileSkipped", "No updates to reconcile") + return ctrl.Result{}, nil + } + } + + if controllerutil.AddFinalizer(instance, databaseProviderFinalizer) { + if err := r.Update(ctx, instance); err != nil { + return r.handleError(ctx, instance, "add-finalizer", err) + } + } + + // Reconcile the RelationalDatabaseProvider and check the unique name of the Connections + uniqueNames := make(map[string]struct{}, len(instance.Spec.Connections)) + conns := make([]reldbConn, 0, len(instance.Spec.Connections)) + for _, conn := range instance.Spec.Connections { + secret := &v1.Secret{} + if err := r.Get(ctx, types.NamespacedName{ + Name: conn.PasswordSecretRef.Name, + Namespace: conn.PasswordSecretRef.Namespace, + }, secret); err != nil { + return r.handleError(ctx, instance, "get-secret", err) + } + password := string(secret.Data["password"]) + if password == "" { + return r.handleError( + ctx, + instance, + fmt.Sprintf("%s-empty-password", instance.Spec.Kind), + fmt.Errorf( + "%s connection secret %s in namespace %s has empty password", + instance.Spec.Kind, secret.Name, secret.Namespace, + ), + ) + } + conns = append(conns, reldbConn{ + kind: instance.Spec.Kind, + name: conn.Name, + hostname: conn.Hostname, + replicaHostnames: conn.ReplicaHostnames, + password: password, + port: conn.Port, + username: conn.Username, + enabled: conn.Enabled, + }) + uniqueNames[conn.Hostname] = struct{}{} + } + + if len(uniqueNames) != len(instance.Spec.Connections) { + return r.handleError( + ctx, + instance, + fmt.Sprintf("%s-unique-name-error", instance.Spec.Kind), + fmt.Errorf("%s database connections must have unique names", instance.Spec.Kind), + ) + } + + dbStatus := make([]crdv1alpha1.ConnectionStatus, 0, len(conns)) + errors := make([]error, 0, len(conns)) + foundEnabledDatabase := false + for _, conn := range conns { + // make a ping to the database to check if it's up and running and we can connect to it + // if not, we should return an error and set the status to 0 + // Note we could periodically check the status of the database and update the status accordingly... + if err := r.RelDBClient.Ping(ctx, conn.getDSN(), instance.Spec.Kind); err != nil { + errors = append(errors, err) + dbStatus = append(dbStatus, crdv1alpha1.ConnectionStatus{ + Name: conn.name, + Hostname: conn.hostname, + Status: "unavailable", + Enabled: conn.enabled, + }) + continue + } + version, err := r.RelDBClient.Version(ctx, conn.getDSN(), instance.Spec.Kind) + if err != nil { + errors = append(errors, err) + dbStatus = append(dbStatus, crdv1alpha1.ConnectionStatus{ + Name: conn.name, + Hostname: conn.hostname, + Status: "unavailable", + Enabled: conn.enabled, + }) + continue + } + + // check if the database is initialized + err = r.RelDBClient.Initialize(ctx, conn.getDSN(), instance.Spec.Kind) + if err != nil { + errors = append(errors, err) + dbStatus = append(dbStatus, crdv1alpha1.ConnectionStatus{ + Name: conn.name, + Hostname: conn.hostname, + Status: "unavailable", + Enabled: conn.enabled, + }) + continue + } + + promRelationalDatabaseProviderConnectionVersion.WithLabelValues( + instance.Spec.Kind, req.Name, instance.Spec.Scope, conn.hostname, conn.username, version).Set(1) + dbStatus = append(dbStatus, crdv1alpha1.ConnectionStatus{ + Name: conn.name, + Hostname: conn.hostname, + DatabaseVersion: version, + Status: "available", + Enabled: conn.enabled, + }) + + if conn.enabled { + foundEnabledDatabase = true + } + } + + instance.Status.ConnectionStatus = dbStatus + instance.Status.ObservedGeneration = instance.Generation + + if len(errors) == len(conns) { + return r.handleError( + ctx, + instance, + fmt.Sprintf("%s-connection-error", instance.Spec.Kind), + fmt.Errorf("failed to connect to any of the %s databases: %v", instance.Spec.Kind, errors), + ) + } + if !foundEnabledDatabase { + return r.handleError( + ctx, + instance, + fmt.Sprintf("%s-connection-not-any-enabled", instance.Spec.Kind), + fmt.Errorf("no enabled working %s database found", instance.Spec.Kind), + ) + } + + // update the status condition to ready + meta.SetStatusCondition(&instance.Status.Conditions, metav1.Condition{ + Type: "Ready", + Status: metav1.ConditionTrue, + Reason: "Reconciled", + Message: "RelationalDatabaseProvider reconciled", + }) + // update the status + if err := r.Status().Update(ctx, instance); err != nil { + promRelationalDatabaseProviderReconcileErrorCounter.WithLabelValues( + instance.Spec.Kind, req.Name, instance.Spec.Scope, "update-status").Inc() + promRelationalDatabaseProviderStatus.WithLabelValues(instance.Spec.Kind, req.Name, instance.Spec.Scope).Set(0) + return ctrl.Result{}, err + } + + r.Recorder.Event(instance, "Normal", "Reconciled", "RelationalDatabaseProvider reconciled") + promRelationalDatabaseProviderStatus.WithLabelValues(instance.Spec.Kind, req.Name, instance.Spec.Scope).Set(1) + return ctrl.Result{}, nil +} + +// handleError handles the error and returns the result and the error +func (r *RelationalDatabaseProviderReconciler) handleError( + ctx context.Context, + instance *crdv1alpha1.RelationalDatabaseProvider, + promErr string, + err error, +) (ctrl.Result, error) { + promRelationalDatabaseProviderReconcileErrorCounter.WithLabelValues( + instance.Spec.Kind, instance.Name, instance.Spec.Scope, promErr).Inc() + promRelationalDatabaseProviderStatus.WithLabelValues(instance.Spec.Kind, instance.Name, instance.Spec.Scope).Set(0) + r.Recorder.Event(instance, v1.EventTypeWarning, errTypeToEventReason(promErr), err.Error()) + + // set the status condition to false + meta.SetStatusCondition(&instance.Status.Conditions, metav1.Condition{ + Type: "Ready", + Status: metav1.ConditionFalse, + Reason: errTypeToEventReason(promErr), + Message: err.Error(), + }) + + // update the status + if err := r.Status().Update(ctx, instance); err != nil { + promRelationalDatabaseProviderReconcileErrorCounter.WithLabelValues( + instance.Spec.Kind, instance.Name, instance.Spec.Scope, "update-status").Inc() + log.FromContext(ctx).Error(err, "Failed to update status") + } + + return ctrl.Result{}, err +} + +// reldbConn is the connection to a MySQL or PostgreSQL database +type reldbConn struct { + kind string + name string + hostname string + replicaHostnames []string + password string + port int + username string + enabled bool +} + +// getDSN constructs the DSN string for the MySQL or PostgreSQL connection. +func (rc *reldbConn) getDSN() string { + if rc.kind == "mysql" { + return fmt.Sprintf("%s:%s@tcp(%s:%d)/", rc.username, rc.password, rc.hostname, rc.port) + } else if rc.kind == "postgresql" { + return fmt.Sprintf( + "host=%s port=%d user=%s password=%s dbname=%s sslmode=disable", + rc.hostname, rc.port, rc.username, rc.password, rc.name, + ) + } else { + return "" + } +} + +// SetupWithManager sets up the controller with the Manager. +func (r *RelationalDatabaseProviderReconciler) SetupWithManager(mgr ctrl.Manager) error { + // register metrics + metrics.Registry.MustRegister( + promRelationalDatabaseProviderReconcileErrorCounter, + promRelationalDatabaseProviderStatus, + promRelationalDatabaseProviderConnectionVersion, + ) + r.Recorder = mgr.GetEventRecorderFor("relationaldatabaseprovider_controller") + return ctrl.NewControllerManagedBy(mgr). + For(&crdv1alpha1.RelationalDatabaseProvider{}). + WithEventFilter(predicate.GenerationChangedPredicate{}). + // let's set the max concurrent reconciles to 1 as we don't want to run multiple reconciles at the same time + // although we could also change this and guard it by the name of the database provider + WithOptions(controller.Options{MaxConcurrentReconciles: 1}). + Complete(r) +} diff --git a/internal/controller/databasemysqlprovider_controller_test.go b/internal/controller/relationaldatabaseprovider_controller_test.go similarity index 67% rename from internal/controller/databasemysqlprovider_controller_test.go rename to internal/controller/relationaldatabaseprovider_controller_test.go index cc19be5..a21d434 100644 --- a/internal/controller/databasemysqlprovider_controller_test.go +++ b/internal/controller/relationaldatabaseprovider_controller_test.go @@ -30,12 +30,12 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" crdv1alpha1 "github.com/uselagoon/dbaas-controller/api/v1alpha1" - "github.com/uselagoon/dbaas-controller/internal/database/mysql" + "github.com/uselagoon/dbaas-controller/internal/database" ) -var _ = Describe("DatabaseMySQLProvider Controller", func() { +var _ = Describe("RelationalDatabaseProvider Controller", func() { Context("When reconciling a resource", func() { - const resourceName = "mysql-provider-test" + const resourceName = "relational-database-provider-test" ctx := context.Background() @@ -43,19 +43,19 @@ var _ = Describe("DatabaseMySQLProvider Controller", func() { Name: resourceName, Namespace: "default", } - databasemysqlprovider := &crdv1alpha1.DatabaseMySQLProvider{} + relationaldatabaseprovider := &crdv1alpha1.RelationalDatabaseProvider{} BeforeEach(func() { - By("creating the custom resource for the Kind DatabaseMySQLProvider") + By("creating the custom resource for the Kind RelationalDatabaseProvider") secret := &v1.Secret{} err := k8sClient.Get(ctx, types.NamespacedName{ - Name: "test-mysql-provider-secret", + Name: "test-rel-db-provider-secret", Namespace: "default", }, secret) if err != nil && errors.IsNotFound(err) { secret = &v1.Secret{ ObjectMeta: metav1.ObjectMeta{ - Name: "test-mysql-provider-secret", + Name: "test-rel-db-provider-secret", Namespace: "default", }, StringData: map[string]string{ @@ -66,16 +66,17 @@ var _ = Describe("DatabaseMySQLProvider Controller", func() { Expect(err).NotTo(HaveOccurred()) } - err = k8sClient.Get(ctx, typeNamespacedName, databasemysqlprovider) + err = k8sClient.Get(ctx, typeNamespacedName, relationaldatabaseprovider) if err != nil && errors.IsNotFound(err) { - resource := &crdv1alpha1.DatabaseMySQLProvider{ + resource := &crdv1alpha1.RelationalDatabaseProvider{ ObjectMeta: metav1.ObjectMeta{ Name: resourceName, Namespace: "default", }, - Spec: crdv1alpha1.DatabaseMySQLProviderSpec{ + Spec: crdv1alpha1.RelationalDatabaseProviderSpec{ + Kind: "mysql", Scope: "custom", - MySQLConnections: []crdv1alpha1.MySQLConnection{ + Connections: []crdv1alpha1.Connection{ { Name: "test-connection", Hostname: "test-hostname", @@ -95,29 +96,29 @@ var _ = Describe("DatabaseMySQLProvider Controller", func() { }) AfterEach(func() { - resource := &crdv1alpha1.DatabaseMySQLProvider{} + resource := &crdv1alpha1.RelationalDatabaseProvider{} err := k8sClient.Get(ctx, typeNamespacedName, resource) Expect(err).NotTo(HaveOccurred()) secret := &v1.Secret{} err = k8sClient.Get(ctx, types.NamespacedName{ - Name: resource.Spec.MySQLConnections[0].PasswordSecretRef.Name, - Namespace: resource.Spec.MySQLConnections[0].PasswordSecretRef.Namespace, + Name: resource.Spec.Connections[0].PasswordSecretRef.Name, + Namespace: resource.Spec.Connections[0].PasswordSecretRef.Namespace, }, secret) Expect(err).NotTo(HaveOccurred()) - By("Cleanup the specific resource instance DatabaseMySQLProvider") + By("Cleanup the specific resource instance RelationalDatabaseProvider") Expect(k8sClient.Delete(ctx, resource)).To(Succeed()) Expect(k8sClient.Delete(ctx, secret)).To(Succeed()) }) It("should successfully reconcile the resource", func() { By("Reconciling the created resource") fakeRecorder := record.NewFakeRecorder(100) - controllerReconciler := &DatabaseMySQLProviderReconciler{ + controllerReconciler := &RelationalDatabaseProviderReconciler{ Client: k8sClient, Scheme: k8sClient.Scheme(), Recorder: fakeRecorder, - MySQLClient: &mysql.MySQLMock{}, + RelDBClient: &database.RelationalDatabaseMock{}, } _, err := controllerReconciler.Reconcile(ctx, reconcile.Request{ @@ -125,11 +126,11 @@ var _ = Describe("DatabaseMySQLProvider Controller", func() { }) Expect(err).NotTo(HaveOccurred()) // check status of the resource - err = k8sClient.Get(ctx, typeNamespacedName, databasemysqlprovider) + err = k8sClient.Get(ctx, typeNamespacedName, relationaldatabaseprovider) Expect(err).NotTo(HaveOccurred()) - Expect(len(databasemysqlprovider.Status.Conditions)).To(Equal(1)) - Expect(databasemysqlprovider.Status.Conditions[0].Status).To(Equal(metav1.ConditionTrue)) - Expect(databasemysqlprovider.Status.Conditions[0].Type).To(Equal("Ready")) + Expect(len(relationaldatabaseprovider.Status.Conditions)).To(Equal(1)) + Expect(relationaldatabaseprovider.Status.Conditions[0].Status).To(Equal(metav1.ConditionTrue)) + Expect(relationaldatabaseprovider.Status.Conditions[0].Type).To(Equal("Ready")) }) }) }) diff --git a/internal/database/database.go b/internal/database/database.go new file mode 100644 index 0000000..43515c2 --- /dev/null +++ b/internal/database/database.go @@ -0,0 +1,506 @@ +package database + +import ( + "context" + "database/sql" + "fmt" + "math/rand" + + _ "github.com/go-sql-driver/mysql" + _ "github.com/lib/pq" + + "sigs.k8s.io/controller-runtime/pkg/log" +) + +const ( + // maxUsernameLength MySQL and PostgreSQL username must use valid characters and be at most 16 characters long + maxUsernameLength = 16 + // maxPasswordLength MySQL and PostgreSQL password must use valid characters and be at most 24 characters long + maxPasswordLength = 24 + // maxDatabaseNameLength MySQL and PostgreSQL database name must use valid characters and be at most 63 characters long + maxDatabaseNameLength = 63 + // mysql is the kind for MySQL + mysql = "mysql" + // postgres is the kind for PostgreSQL + postgres = "postgres" +) + +// RelationalDatabaseInfo contains the username, password, and database name of a relational database +type RelationalDatabaseInfo struct { + // Username is the username for the database + Username string + // Password is the password for the database + Password string + // Dbname is the database name + Dbname string +} + +// RelationalDatabaseInterface is the interface for a relational database +// Note that the implementation of this interface should be idempotent. +type RelationalDatabaseInterface interface { + // GetConnection returns a connection to the relational database + GetConnection(ctx context.Context, dsn string, kind string) (*sql.DB, error) + + // Ping pings the relational database + Ping(ctx context.Context, dsn string, kind string) error + + // Version returns the version of the relational database + Version(ctx context.Context, dsn string, kind string) (string, error) + + // Load of the database measured in MB of data and index size. + // Higher values indicate more data and indexes. + Load(ctx context.Context, dsn string, kind string) (int, error) + + // Initialize initializes the relational database + // This is used by the database {MySQL,PostgreSQL} provider to initialize the relational database. + // It does setup the dbass_controller database. + // This function is idempotent and can be called multiple times without side effects. + Initialize(ctx context.Context, dsn string, kind string) error + + // CreateDatabase creates a database in the relational database if it does not exist. + // It also creates a user and grants the user permissions on the database. + // This function is idempotent and can be called multiple times without side effects. + // returns the database name, username, and password + CreateDatabase(ctx context.Context, dsn, name, namespace, kind string) (RelationalDatabaseInfo, error) + + // DropDatabase drops a database in the MySQL or PostgreSQL database if it exists. + // This function is idempotent and can be called multiple times without side effects. + DropDatabase(ctx context.Context, dsn, name, namespace, kind string) error + + // GetDatabase returns the database name, username, and password for the given name and namespace. + GetDatabase(ctx context.Context, dsn, name, namespace, kind string) (RelationalDatabaseInfo, error) +} + +// RelationalDatabaseImpl is the implementation of the RelationalDatabaseInterface +type RelationalDatabaseImpl struct { + connectionCache map[string]*sql.DB +} + +// Make sure RelationalDatabaseBasicImpl implements RelationalDatabaseBasicInterface +var _ RelationalDatabaseInterface = (*RelationalDatabaseImpl)(nil) + +// NewRelationalDatabaseBasicImpl creates a new RelationalDatabaseBasicImpl +func New() *RelationalDatabaseImpl { + return &RelationalDatabaseImpl{ + connectionCache: make(map[string]*sql.DB), + } +} + +// GetConnection returns a connection to the MySQL or PostgreSQL database +func (ri *RelationalDatabaseImpl) GetConnection(ctx context.Context, dsn string, kind string) (*sql.DB, error) { + if db, ok := ri.connectionCache[dsn]; ok { + return db, nil + } + db, err := sql.Open(kind, dsn) + if err != nil { + return nil, fmt.Errorf("failed to open %s database: %w", kind, err) + } + log.FromContext(ctx).Info("Opening %s database connection", "kind", kind) + ri.connectionCache[dsn] = db + return db, nil +} + +// Ping pings the relational database +func (ri *RelationalDatabaseImpl) Ping(ctx context.Context, dsn string, kind string) error { + log.FromContext(ctx).Info("Pinging database", "kind", kind) + db, err := ri.GetConnection(ctx, dsn, kind) + if err != nil { + return fmt.Errorf("ping failed to open %s database: %w", kind, err) + } + + if err := db.PingContext(ctx); err != nil { + return fmt.Errorf("failed to ping %s database: %w", kind, err) + } + + return nil +} + +// Version returns the version of the MySQL or PostgreSQL database +func (ri *RelationalDatabaseImpl) Version(ctx context.Context, dsn string, kind string) (string, error) { + log.FromContext(ctx).Info("Getting database version", "kind", kind) + db, err := ri.GetConnection(ctx, dsn, kind) + if err != nil { + return "", fmt.Errorf("version failed to open %s database: %w", kind, err) + } + + var version string + err = db.QueryRowContext(ctx, "SELECT VERSION()").Scan(&version) + if err != nil { + return "", fmt.Errorf("version failed to get %s database version: %w", kind, err) + } + + return version, nil +} + +// Load returns the load of the MySQL or PostgreSQL database measured in MB of data and index size. +// Note it doesn't include CPU or memory usage which could be obtained from other sources. +func (ri *RelationalDatabaseImpl) Load(ctx context.Context, dsn string, kind string) (int, error) { + log.FromContext(ctx).Info("Getting database load", "kind", kind) + db, err := ri.GetConnection(ctx, dsn, kind) + if err != nil { + return 0, fmt.Errorf("load failed to open %s database: %w", kind, err) + } + + var totalLoad float64 + if kind == mysql { + err = db.QueryRowContext(ctx, "SELECT data_length + index_length FROM information_schema.tables").Scan(&totalLoad) + if err != nil { + return 0, fmt.Errorf("load failed to get %s database load: %w", kind, err) + } + } else if kind == postgres { + err = db.QueryRowContext(ctx, "SELECT pg_database_size(current_database())").Scan(&totalLoad) + if err != nil { + return 0, fmt.Errorf("load failed to get %s database load: %w", kind, err) + } + } else { + return 0, fmt.Errorf("load failed to get %s database load: unsupported kind", kind) + } + // convert bytes to MB + totalLoadMB := totalLoad / 1024 / 1024 + return int(totalLoadMB), nil +} + +// Initialize initializes the MySQL or PostgreSQL database +// This is used by the database {MySQL,PostgreSQL} provider to initialize the MySQL or PostgreSQL database. +// It does setup the dbass_controller database. +// This function is idempotent and can be called multiple times without side effects. +func (ri *RelationalDatabaseImpl) Initialize(ctx context.Context, dsn string, kind string) error { + log.FromContext(ctx).Info("Initializing database", "kind", kind) + db, err := ri.GetConnection(ctx, dsn, kind) + if err != nil { + return fmt.Errorf("initialize failed to open %s database: %w", kind, err) + } + + if kind == mysql { + _, err = db.ExecContext(ctx, "CREATE DATABASE IF NOT EXISTS dbaas_controller") + if err != nil { + return fmt.Errorf("initialize failed to create %s database: %w", kind, err) + } + + _, err = db.ExecContext(ctx, "USE dbaas_controller") + if err != nil { + return fmt.Errorf("initialize failed to use %s database: %w", kind, err) + } + + _, err = db.ExecContext(ctx, ` + CREATE TABLE IF NOT EXISTS users ( + id INT AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(255) NOT NULL, + namespace VARCHAR(255) NOT NULL, + username VARCHAR(16) NOT NULL UNIQUE, + password VARCHAR(255) NOT NULL, + dbname VARCHAR(255) NOT NULL UNIQUE, + CONSTRAINT unique_name_namespace UNIQUE (name, namespace) + ) ENGINE=InnoDB;`) + if err != nil { + return fmt.Errorf("initialize failed to create %s table: %w", kind, err) + } + } else if kind == postgres { + _, err := db.ExecContext( + ctx, + "SELECT 'CREATE DATABASE dbaas_controller' WHERE NOT EXISTS (SELECT FROM pg_database WHERE datname = 'dbaas_controller')", // nolint: lll + ) + if err != nil { + return fmt.Errorf("initialize failed to create %s database: %w", kind, err) + } + + _, err = db.ExecContext(ctx, ` + CREATE TABLE IF NOT EXISTS dbaas_controller.users ( + id SERIAL PRIMARY KEY, + name VARCHAR(255) NOT NULL, + namespace VARCHAR(255) NOT NULL, + username VARCHAR(16) NOT NULL UNIQUE, + password VARCHAR(255) NOT NULL, + dbname VARCHAR(255) NOT NULL UNIQUE + CONSTRAINT unique_name_namespace UNIQUE (name, namespace) + );`) + if err != nil { + return fmt.Errorf("initialize failed to create %s table: %w", kind, err) + } + } else { + return fmt.Errorf("initialize failed to initialize %s database: unsupported kind", kind) + } + + return nil +} + +// CreateDatabase creates a database in the MySQL or PostgreSQL server if it does not exist. +func (ri *RelationalDatabaseImpl) CreateDatabase( + ctx context.Context, + dsn, name, namespace string, + kind string, +) (RelationalDatabaseInfo, error) { + log.FromContext(ctx).Info("Creating database", "kind", kind) + db, err := ri.GetConnection(ctx, dsn, kind) + if err != nil { + return RelationalDatabaseInfo{}, fmt.Errorf("create database failed to open %s database: %w", kind, err) + } + + var info RelationalDatabaseInfo + if kind == mysql { + info, err = ri.databaseInfoMySQL(ctx, dsn, name, namespace) + if err != nil { + return info, fmt.Errorf("create %s database failed to get database info: %w", kind, err) + } + // Create the database + _, err = db.ExecContext(ctx, fmt.Sprintf("CREATE DATABASE IF NOT EXISTS `%s`", info.Dbname)) + if err != nil { + return info, fmt.Errorf( + "create %s database error in creating the database `%s`: %w", kind, info.Dbname, err) + } + // Create the user and grant permissions + // Use prepared statements to avoid SQL injection vulnerabilities. + _, err = db.ExecContext( + ctx, fmt.Sprintf("CREATE USER IF NOT EXISTS '%s'@'%%' IDENTIFIED BY '%s'", info.Username, info.Password)) + if err != nil { + return info, fmt.Errorf("create %s database error creating user `%s`: %w", kind, info.Username, err) + } + + _, err = db.ExecContext(ctx, fmt.Sprintf( + "GRANT SELECT, INSERT, UPDATE, DELETE, CREATE, DROP, REFERENCES, "+ + "INDEX, ALTER, CREATE TEMPORARY TABLES, LOCK TABLES, "+ + "EXECUTE, CREATE VIEW, SHOW VIEW, CREATE ROUTINE, ALTER ROUTINE, EVENT, TRIGGER ON `%s`.* TO '%s'@'%%'", + info.Dbname, info.Username)) + if err != nil { + return info, fmt.Errorf( + "create %s database error granting privileges to user `%s` on database `%s`: %w", + kind, info.Username, info.Dbname, err) + } + + _, err = db.ExecContext(ctx, "FLUSH PRIVILEGES") + if err != nil { + return info, fmt.Errorf("create %s database error flushing privileges: %w", kind, err) + } + } else if kind == postgres { + info, err = ri.databaseInfoPostgreSQL(ctx, dsn, name, namespace) + if err != nil { + return info, fmt.Errorf("create database failed to get %s database info: %w", kind, err) + } + // Create the database + _, err = db.Exec( + fmt.Sprintf( + "SELECT 'CREATE DATABASE \"%s\"' WHERE NOT EXISTS (SELECT FROM pg_database WHERE datname = '%s')", + info.Dbname, info.Dbname, + ), + ) + if err != nil { + return info, fmt.Errorf( + "create %s database error in creating the database `%s`: %w", kind, info.Dbname, err) + } + + // Check if user exists and create or update the user + var userExists int + err = db.QueryRow(fmt.Sprintf("SELECT 1 FROM pg_roles WHERE rolname='%s'", info.Username)).Scan(&userExists) + if err != nil && err != sql.ErrNoRows { + return info, fmt.Errorf( + "create %s database error in check if user exists in database `%s`: %w", kind, info.Dbname, err) + } + + if userExists == 0 { + // Create the user with encrypted password + _, err = db.Exec(fmt.Sprintf("CREATE USER \"%s\" WITH ENCRYPTED PASSWORD '%s'", info.Username, info.Password)) + if err != nil { + return info, fmt.Errorf( + "create %s database error in create user in database `%s`: %w", kind, info.Dbname, err) + } + } + + // Grant privileges + _, err = db.Exec(fmt.Sprintf("GRANT ALL PRIVILEGES ON DATABASE \"%s\" TO \"%s\"", info.Dbname, info.Username)) + if err != nil { + return info, fmt.Errorf( + "create %s database error in grant privileges in database `%s`: %w", kind, info.Dbname, err) + } + } else { + return RelationalDatabaseInfo{}, fmt.Errorf("create database failed to create %s database: unsupported kind", kind) + } + + return info, nil +} + +// DropDatabase drops a database in the MySQL or PostgreSQL database if it exists. +func (ri *RelationalDatabaseImpl) DropDatabase(ctx context.Context, dsn, name, namespace, kind string) error { + log.FromContext(ctx).Info("Dropping database", "kind", kind) + db, err := ri.GetConnection(ctx, dsn, kind) + if err != nil { + return fmt.Errorf("drop database failed to open %s database: %w", kind, err) + } + + info := RelationalDatabaseInfo{} + if kind == mysql { + info, err = ri.databaseInfoMySQL(ctx, dsn, name, namespace) + if err != nil { + return fmt.Errorf("drop database failed to get database info: %w", err) + } + // Drop the database + _, err = db.ExecContext(ctx, fmt.Sprintf("DROP DATABASE IF EXISTS `%s`", info.Dbname)) + if err != nil { + return fmt.Errorf("drop database failed to drop %s database: %w", kind, err) + } + // Drop the user + _, err = db.ExecContext(ctx, fmt.Sprintf("DROP USER IF EXISTS '%s'@'%%'", info.Username)) + if err != nil { + return fmt.Errorf("drop database failed to drop user: %w", err) + } + // flush privileges + _, err = db.ExecContext(ctx, "FLUSH PRIVILEGES") + if err != nil { + return fmt.Errorf("drop database failed to flush privileges: %w", err) + } + } else if kind == postgres { + info, err = ri.databaseInfoPostgreSQL(ctx, dsn, name, namespace) + if err != nil { + return fmt.Errorf("drop database failed to get database info: %w", err) + } + // Disconnect all users from the database + _, err = db.Exec( + fmt.Sprintf( + "SELECT pg_terminate_backend(pg_stat_activity.pid) FROM pg_stat_activity WHERE pg_stat_activity.datname = '%s' AND pid <> pg_backend_pid()", // nolint: lll + info.Dbname, + ), + ) + if err != nil { + return fmt.Errorf("drop database failed to disconnect users from %s database: %w", kind, err) + } + // Drop the database + _, err = db.Exec(fmt.Sprintf("DROP DATABASE IF EXISTS \"%s\"", info.Dbname)) + if err != nil { + return fmt.Errorf("drop database failed to drop %s database: %w", kind, err) + } + // Drop the user + _, err = db.Exec(fmt.Sprintf("DROP USER IF EXISTS \"%s\"", info.Username)) + if err != nil { + return fmt.Errorf("drop database failed to drop user: %w", err) + } + } else { + return fmt.Errorf("drop database failed to drop %s database: unsupported kind", kind) + } + return nil +} + +// generateRandomString generates a random string of specified length +func generateRandomString(length int) string { + const chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" + result := make([]byte, length) + for i := range result { + result[i] = chars[rand.Intn(len(chars))] + } + return string(result) +} + +// createUserInfo creates a random username, password, and database name +func createUserInfo(_ context.Context, namespace string) RelationalDatabaseInfo { + info := RelationalDatabaseInfo{ + Username: generateRandomString(maxUsernameLength), + Password: generateRandomString(maxPasswordLength), + } + + dbnamePrefix := namespace + if len(dbnamePrefix) > 50 { + dbnamePrefix = dbnamePrefix[:50] + } + + info.Dbname = fmt.Sprintf( + "%s_%s", dbnamePrefix, generateRandomString(maxDatabaseNameLength-len(dbnamePrefix)-1)) + + return info +} + +// databaseInfoMySQL returns the username, password, and database name for the given name and namespace. +// It also creates the user and database if they do not exist. +// This function is idempotent and can be called multiple times without side effects. +func (ri *RelationalDatabaseImpl) databaseInfoMySQL( + ctx context.Context, + dsn, name, namespace string, +) (RelationalDatabaseInfo, error) { + var info RelationalDatabaseInfo + + db, err := ri.GetConnection(ctx, dsn, mysql) + if err != nil { + return RelationalDatabaseInfo{}, fmt.Errorf("create database failed to open %s database: %w", mysql, err) + } + + _, err = db.ExecContext(ctx, "USE dbaas_controller") + if err != nil { + return info, fmt.Errorf("failed to select database: %w", err) + } + + // Check if the username and database name already exist + // Use prepared statements to avoid SQL injection vulnerabilities. + err = db.QueryRowContext( + ctx, "SELECT username, password, dbname FROM users WHERE name = ? AND namespace = ?", name, namespace).Scan( + &info.Username, + &info.Password, + &info.Dbname, + ) + if err != nil { + // check if the error is a not found error + if err != sql.ErrNoRows { + return info, fmt.Errorf("failed %s to query users table: %w", mysql, err) + } + + info = createUserInfo(ctx, namespace) + // Insert the user into the users table + _, err = db.ExecContext( + ctx, "INSERT INTO users (name, namespace, username, password, dbname) VALUES (?, ?, ?, ?, ?)", + name, namespace, info.Username, info.Password, info.Dbname) + if err != nil { + return info, fmt.Errorf("failed to insert user into users table: %w", err) + } + } + return info, nil +} + +// databaseInfoPostgreSQL returns the username, password, and database name for the given name and namespace. +// It also creates the user and database if they do not exist. +// This function is idempotent and can be called multiple times without side effects. +func (ri *RelationalDatabaseImpl) databaseInfoPostgreSQL( + ctx context.Context, + dsn, name, namespace string, +) (RelationalDatabaseInfo, error) { + var info RelationalDatabaseInfo + + db, err := ri.GetConnection(ctx, dsn, postgres) + if err != nil { + return RelationalDatabaseInfo{}, fmt.Errorf("create database failed to open %s database: %w", postgres, err) + } + + // select username, password and dbname from the users table + err = db.QueryRowContext( + ctx, "SELECT username, password, dbname FROM dbaas_controller.users WHERE name = $1 AND namespace = $2", + name, namespace).Scan( + &info.Username, + &info.Password, + &info.Dbname, + ) + if err != nil { + // check if the error is a not found error + if err != sql.ErrNoRows { + return info, fmt.Errorf("failed %s to query users table: %w", postgres, err) + } + info = createUserInfo(ctx, namespace) + // Insert the user into the users table + _, err = db.ExecContext( + ctx, "INSERT INTO dbaas_controller.users (name, namespace, username, password, dbname) VALUES ($1, $2, $3, $4, $5)", + name, namespace, info.Username, info.Password, info.Dbname) + if err != nil { + return info, fmt.Errorf("failed to insert user into users table: %w", err) + } + } + + return info, nil +} + +// GetDatabase returns the database name, username, and password for the given name and namespace. +func (ri *RelationalDatabaseImpl) GetDatabase( + ctx context.Context, + dsn, name, namespace, kind string, +) (RelationalDatabaseInfo, error) { + log.FromContext(ctx).Info("Getting database", "kind", kind, "name", name, "namespace", namespace) + if kind == "mysql" { + return ri.databaseInfoMySQL(ctx, dsn, name, namespace) + } else if kind == "postgres" { + return ri.databaseInfoPostgreSQL(ctx, dsn, name, namespace) + } + return RelationalDatabaseInfo{}, fmt.Errorf("get database failed to get %s database: unsupported kind", kind) +} diff --git a/internal/database/mock.go b/internal/database/mock.go new file mode 100644 index 0000000..990fe4d --- /dev/null +++ b/internal/database/mock.go @@ -0,0 +1,49 @@ +package database + +import ( + "context" + "database/sql" +) + +// Make sure RelationalDatabaseMock implements RelationalDatabaseInterface +var _ RelationalDatabaseInterface = (*RelationalDatabaseMock)(nil) + +// RelationalDatabaseMock is a mock implementation of the RelationalDatabase database +type RelationalDatabaseMock struct{} + +// Ping pings the relational database +func (mi *RelationalDatabaseMock) Ping(ctx context.Context, dsn string, kind string) error { + return nil +} + +// Version returns the version of the RelationalDatabase database +func (mi *RelationalDatabaseMock) Version(ctx context.Context, dsn string, kind string) (string, error) { + return "5.7.34", nil +} + +func (mi *RelationalDatabaseMock) Initialize(ctx context.Context, dsn string, kind string) error { + return nil +} + +// CreateDatabase creates a database in the relational database if it does not exist. +func (mi *RelationalDatabaseMock) CreateDatabase( + ctx context.Context, dsn, name, namespace, kind string) (RelationalDatabaseInfo, error) { + return RelationalDatabaseInfo{Username: "user", Password: "pass", Dbname: "db"}, nil +} + +func (mi *RelationalDatabaseMock) DropDatabase(ctx context.Context, dsn, name, namespace, kind string) error { + return nil +} + +func (mi *RelationalDatabaseMock) GetDatabase( + ctx context.Context, dsn, name, namespace, kind string) (RelationalDatabaseInfo, error) { + return RelationalDatabaseInfo{Username: "user", Password: "pass", Dbname: "db"}, nil +} + +func (mi *RelationalDatabaseMock) Load(ctx context.Context, dsn string, kind string) (int, error) { + return 10, nil +} + +func (mi *RelationalDatabaseMock) GetConnection(ctx context.Context, dsn string, kind string) (*sql.DB, error) { + return nil, nil +} diff --git a/internal/database/mysql/mock.go b/internal/database/mysql/mock.go deleted file mode 100644 index bdda914..0000000 --- a/internal/database/mysql/mock.go +++ /dev/null @@ -1,40 +0,0 @@ -package mysql - -import "context" - -// Make sure MySQLMock implements MySQLInterface -var _ MySQLInterface = (*MySQLMock)(nil) - -// MySQLMock is a mock implementation of the MySQL database -type MySQLMock struct{} - -// Ping pings the MySQL database -func (mi *MySQLMock) Ping(ctx context.Context, dsn string) error { - return nil -} - -// Version returns the version of the MySQL database -func (mi *MySQLMock) Version(ctx context.Context, dsn string) (string, error) { - return "5.7.34", nil -} - -func (mi *MySQLMock) Initialize(ctx context.Context, dsn string) error { - return nil -} - -// CreateDatabase creates a database in the MySQL database if it does not exist. -func (mi *MySQLMock) CreateDatabase(ctx context.Context, dsn, name, namespace string) (DatabaseInfo, error) { - return DatabaseInfo{Username: "user", Password: "pass", Dbname: "db"}, nil -} - -func (mi *MySQLMock) DropDatabase(ctx context.Context, dsn, name, namespace string) error { - return nil -} - -func (mi *MySQLMock) GetDatabase(ctx context.Context, dsn, name, namespace string) (DatabaseInfo, error) { - return DatabaseInfo{Username: "user", Password: "pass", Dbname: "db"}, nil -} - -func (mi *MySQLMock) Load(ctx context.Context, dsn string) (int, error) { - return 10, nil -} diff --git a/internal/database/mysql/mysql.go b/internal/database/mysql/mysql.go deleted file mode 100644 index 57e0c46..0000000 --- a/internal/database/mysql/mysql.go +++ /dev/null @@ -1,346 +0,0 @@ -package mysql - -import ( - "context" - "database/sql" - "fmt" - "math/rand" - - _ "github.com/go-sql-driver/mysql" - "sigs.k8s.io/controller-runtime/pkg/log" -) - -// DatabaseInfo contains the username, password, and database name -type DatabaseInfo struct { - // Username is the username for the database - Username string - // Password is the password for the database - Password string - // Dbname is the database name - Dbname string -} - -// MySQLInterface is the interface for the MySQL database -type MySQLInterface interface { - // Ping pings the MySQL database - Ping(ctx context.Context, dsn string) error - - // Version returns the version of the MySQL database - Version(ctx context.Context, dsn string) (string, error) - - // Load of the MySQL database measured in MB of data and index size. - // Higher values indicate more data and indexes. - Load(ctx context.Context, dsn string) (int, error) - - // Initialize initializes the MySQL database - // This is used by the database MySQL provider to initialize the MySQL database. - // It does setup the dbass_controller database. - // This function is idempotent and can be called multiple times without side effects. - Initialize(ctx context.Context, dsn string) error - - // CreateDatabase creates a database in the MySQL database if it does not exist. - // It also creates a user and grants the user permissions on the database. - // This function is idempotent and can be called multiple times without side effects. - // returns the database name, username, and password - CreateDatabase(ctx context.Context, dsn, name, namespace string) (DatabaseInfo, error) - - // DropDatabase drops a database in the MySQL database if it exists. - // This function is idempotent and can be called multiple times without side effects. - DropDatabase(ctx context.Context, dsn, name, namespace string) error - - // GetDatabase returns the database name, username, and password for the given name and namespace. - GetDatabase(ctx context.Context, dsn, name, namespace string) (DatabaseInfo, error) -} - -// MySQLImpl is the implementation of the MySQL database -type MySQLImpl struct{} - -// Make sure MySQLImpl implements MySQLInterface -var _ MySQLInterface = (*MySQLImpl)(nil) - -// Ping pings the MySQL database -func (mi *MySQLImpl) Ping(ctx context.Context, dsn string) error { - log.FromContext(ctx).Info("Pinging MySQL database") - db, err := sql.Open("mysql", dsn) - if err != nil { - return fmt.Errorf("ping failed to open MySQL database: %w", err) - } - defer db.Close() - - if err := db.PingContext(ctx); err != nil { - return fmt.Errorf("failed to ping MySQL database: %w", err) - } - - return nil -} - -// Version returns the version of the MySQL database -func (mi *MySQLImpl) Version(ctx context.Context, dsn string) (string, error) { - log.FromContext(ctx).Info("Getting MySQL database version") - db, err := sql.Open("mysql", dsn) - if err != nil { - return "", fmt.Errorf("version failed to open MySQL database: %w", err) - } - defer db.Close() - - var version string - err = db.QueryRowContext(ctx, "SELECT VERSION()").Scan(&version) - if err != nil { - return "", fmt.Errorf("version failed to get MySQL database version: %w", err) - } - - return version, nil -} - -// Load returns the load of the MySQL database measured in MB of data and index size. -// Note it doesn't include CPU or memory usage which could be obtained from other sources. -func (mi *MySQLImpl) Load(ctx context.Context, dsn string) (int, error) { - log.FromContext(ctx).Info("Getting MySQL database load") - db, err := sql.Open("mysql", dsn) - if err != nil { - return 0, fmt.Errorf("load failed to open MySQL database: %w", err) - } - defer db.Close() - - query := ` - SELECT SUM(data_length + index_length) AS total_size - FROM information_schema.TABLES - ` - var totalLoad float64 - row := db.QueryRow(query) - err = row.Scan(&totalLoad) - if err != nil { - return 0, fmt.Errorf("load failed to get MySQL database load: %w", err) - } - totalLoadMB := totalLoad / (1024 * 1024) - return int(totalLoadMB), nil -} - -// Initialize sets up the dbaas_controller database and the users table. -func (mi *MySQLImpl) Initialize(ctx context.Context, dsn string) error { - log.FromContext(ctx).Info("Initializing MySQL database") - - // Connect to MySQL server without specifying a database - db, err := sql.Open("mysql", dsn) - if err != nil { - return err - } - defer db.Close() - - // Create the database if it doesn't exist - _, err = db.ExecContext(ctx, "CREATE DATABASE IF NOT EXISTS dbaas_controller") - if err != nil { - return fmt.Errorf("failed to create database: %w", err) - } - - // Select the database - _, err = db.ExecContext(ctx, "USE dbaas_controller") - if err != nil { - return fmt.Errorf("failed to select database: %w", err) - } - - // Create the users table if it doesn't exist - createTableSQL := ` - CREATE TABLE IF NOT EXISTS users ( - id INT AUTO_INCREMENT PRIMARY KEY, - name VARCHAR(255) NOT NULL, - namespace VARCHAR(255) NOT NULL, - username VARCHAR(16) NOT NULL UNIQUE, - password VARCHAR(255) NOT NULL, - dbname VARCHAR(255) NOT NULL UNIQUE, - CONSTRAINT unique_name_namespace UNIQUE (name, namespace) - ) ENGINE=InnoDB;` - - _, err = db.ExecContext(ctx, createTableSQL) - if err != nil { - return fmt.Errorf("failed to create users table: %w", err) - } - - return nil -} - -// generateRandomString generates a random string of specified length -func generateRandomString(length int) string { - const chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" - result := make([]byte, length) - for i := range result { - result[i] = chars[rand.Intn(len(chars))] - } - return string(result) -} - -func (mi *MySQLImpl) databaseInfo(ctx context.Context, dsn, namespace, name string) (DatabaseInfo, error) { - log.FromContext(ctx).Info("creating a username and database in the dbaas_controller database") - - // MySQL username must use valid characters and be at most 16 characters long - const ( - maxUsernameLength = 16 - maxPasswordLength = 24 - maxDatabaseNameLength = 64 - ) - - info := DatabaseInfo{} - - // Connect to MySQL server and select the dbaas_controller database - db, err := sql.Open("mysql", dsn) - if err != nil { - return info, fmt.Errorf("failed to connect to MySQL server: %w", err) - } - defer db.Close() - - _, err = db.ExecContext(ctx, "USE dbaas_controller") - if err != nil { - return info, fmt.Errorf("failed to select database: %w", err) - } - - // Check if the username and database name already exist - // Use prepared statements to avoid SQL injection vulnerabilities. - err = db.QueryRowContext( - ctx, "SELECT username, password, dbname FROM users WHERE name = ? AND namespace = ?", name, namespace).Scan( - &info.Username, - &info.Password, - &info.Dbname, - ) - if err != nil { - // check if the error is a not found error - if err == sql.ErrNoRows { - info.Username = generateRandomString(maxUsernameLength) - info.Password = generateRandomString(maxPasswordLength) - - dbnamePrefix := namespace - if len(dbnamePrefix) > 50 { - dbnamePrefix = dbnamePrefix[:50] - } - - info.Dbname = fmt.Sprintf( - "%s_%s", dbnamePrefix, generateRandomString(maxDatabaseNameLength-len(dbnamePrefix)-1)) - - // Insert the user into the users table - _, err = db.ExecContext( - ctx, "INSERT INTO users (name, namespace, username, password, dbname) VALUES (?, ?, ?, ?, ?)", - name, namespace, info.Username, info.Password, info.Dbname) - if err != nil { - return info, fmt.Errorf("failed to insert user into users table: %w", err) - } - } else { - return info, fmt.Errorf("failed to query users table: %w", err) - } - } - return info, nil -} - -// CreateDatabase creates a database in the MySQL database if it does not exist. -// It also creates a user and grants the user permissions on the database. -// This function is idempotent and can be called multiple times without side effects. -func (mi *MySQLImpl) CreateDatabase(ctx context.Context, dsn, name, namespace string) (DatabaseInfo, error) { - log.FromContext(ctx).Info("Creating MySQL database", "name", name, "namespace", namespace) - - info, err := mi.databaseInfo(ctx, dsn, namespace, name) - if err != nil { - return info, fmt.Errorf("failed to get database info: %w", err) - } - // Connect to the database server - db, err := sql.Open("mysql", dsn) - if err != nil { - return info, fmt.Errorf("create database error connecting to the database server: %w", err) - } - defer db.Close() - - // Ping the database to verify connection establishment. - if err := db.PingContext(ctx); err != nil { - return info, fmt.Errorf( - "create database error verifying connection to the database server: %w", err) - } - - // Create the database - _, err = db.ExecContext(ctx, fmt.Sprintf("CREATE DATABASE IF NOT EXISTS `%s`", info.Dbname)) - if err != nil { - return info, fmt.Errorf( - "create database error in creating the database `%s`: %w", info.Dbname, err) - } - - // Create the user and grant permissions - // Use prepared statements to avoid SQL injection vulnerabilities. - _, err = db.ExecContext( - ctx, fmt.Sprintf("CREATE USER IF NOT EXISTS '%s'@'%%' IDENTIFIED BY '%s'", info.Username, info.Password)) - if err != nil { - return info, fmt.Errorf("create database error creating user `%s`: %w", info.Username, err) - } - - _, err = db.ExecContext(ctx, fmt.Sprintf( - "GRANT SELECT, INSERT, UPDATE, DELETE, CREATE, DROP, REFERENCES, "+ - "INDEX, ALTER, CREATE TEMPORARY TABLES, LOCK TABLES, "+ - "EXECUTE, CREATE VIEW, SHOW VIEW, CREATE ROUTINE, ALTER ROUTINE, EVENT, TRIGGER ON `%s`.* TO '%s'@'%%'", - info.Dbname, info.Username)) - if err != nil { - return info, fmt.Errorf( - "create database error granting privileges to user `%s` on database `%s`: %w", info.Username, info.Dbname, err) - } - - _, err = db.ExecContext(ctx, "FLUSH PRIVILEGES") - if err != nil { - return info, fmt.Errorf("create database error flushing privileges: %w", err) - } - - return info, nil -} - -func (mi *MySQLImpl) DropDatabase(ctx context.Context, dsn, name, namespace string) error { - log.FromContext(ctx).Info("Dropping MySQL database", "name", name, "namespace", namespace) - - info, err := mi.databaseInfo(ctx, dsn, namespace, name) - if err != nil { - return fmt.Errorf("failed to get database info: %w", err) - } - - // Connect to the database server - db, err := sql.Open("mysql", dsn) - if err != nil { - return fmt.Errorf("drop database error connecting to the database server: %w", err) - } - defer db.Close() - - // Ping the database to verify connection establishment. - if err := db.PingContext(ctx); err != nil { - return fmt.Errorf("drop database error verifying connection to the database server: %w", err) - } - - // Drop the database - _, err = db.ExecContext(ctx, fmt.Sprintf("DROP DATABASE IF EXISTS `%s`", info.Dbname)) - if err != nil { - if err == sql.ErrNoRows { - log.FromContext(ctx).Info("Database does not exist", "name", name, "namespace", namespace, "dbname", info.Dbname) - } else { - return fmt.Errorf("drop database error in dropping the database `%s`: %w", info.Dbname, err) - } - } else { - log.FromContext(ctx).Info("Dropped database", "name", name, "namespace", namespace, "dbname", info.Dbname) - } - - // Delete the user - _, err = db.ExecContext(ctx, fmt.Sprintf("DROP USER IF EXISTS '%s'@'%%'", info.Username)) - if err != nil { - if err == sql.ErrNoRows { - log.FromContext(ctx).Info("User does not exist", "name", name, "namespace", namespace, "username", info.Username) - return nil - } else { - return fmt.Errorf("drop database error in dropping user `%s`: %w", info.Username, err) - } - } else { - log.FromContext(ctx).Info("Dropped user", "name", name, "namespace", namespace, "username", info.Username) - } - - return nil -} - -// GetDatabase returns the database name, username, and password for the given name and namespace. -func (mi *MySQLImpl) GetDatabase(ctx context.Context, dsn, name, namespace string) (DatabaseInfo, error) { - log.FromContext(ctx).Info("Getting MySQL database", "name", name, "namespace", namespace) - - info, err := mi.databaseInfo(ctx, dsn, namespace, name) - if err != nil { - return info, fmt.Errorf("failed to get database info: %w", err) - } - - return info, nil -} diff --git a/test/e2e/e2e_test.go b/test/e2e/e2e_test.go index 7d80416..5b20921 100644 --- a/test/e2e/e2e_test.go +++ b/test/e2e/e2e_test.go @@ -52,20 +52,21 @@ var _ = Describe("controller", Ordered, func() { By("uninstalling the cert-manager bundle") utils.UninstallCertManager() - By("removing the DatabaseMySQLProvider resource") + By("removing the RelationalDatabaseProvider resource") // we enforce the deletion by removing the finalizer cmd := exec.Command( "kubectl", "patch", - "databasemysqlprovider", - "databasemysqlprovider-sample", + "relationaldatabaseprovider", + "relationaldatabaseprovider-mysql-sample", "-p", `{"metadata":{"finalizers":[]}}`, "--type=merge", ) _, _ = utils.Run(cmd) - cmd = exec.Command("kubectl", "delete", "--force", "databasemysqlprovider", "databasemysqlprovider-sample") + cmd = exec.Command( + "kubectl", "delete", "--force", "relationaldatabaseprovider", "relationaldatabaseprovider-mysql-sample") _, _ = utils.Run(cmd) By("removing the DatabaseRequest resource") @@ -74,13 +75,13 @@ var _ = Describe("controller", Ordered, func() { "kubectl", "patch", "databaserequest", - "databaserequest-sample", + "databaserequest-mysql-sample", "-p", `{"metadata":{"finalizers":[]}}`, "--type=merge", ) _, _ = utils.Run(cmd) - cmd = exec.Command("kubectl", "delete", "--force", "databaserequest", "databaserequest-sample") + cmd = exec.Command("kubectl", "delete", "--force", "databaserequest", "databaserequest-mysql-sample") _, _ = utils.Run(cmd) By("removing manager namespace") @@ -92,10 +93,10 @@ var _ = Describe("controller", Ordered, func() { By("removing service and secret") cmd = exec.Command( - "kubectl", "delete", "service", "-n", "default", "-l", "app.kubernetes.io/instance=databaserequest-sample") + "kubectl", "delete", "service", "-n", "default", "-l", "app.kubernetes.io/instance=databaserequest-mysql-sample") _, _ = utils.Run(cmd) cmd = exec.Command( - "kubectl", "delete", "secret", "-n", "default", "-l", "app.kubernetes.io/instance=databaserequest-sample") + "kubectl", "delete", "secret", "-n", "default", "-l", "app.kubernetes.io/instance=databaserequest-mysql-sample") _, _ = utils.Run(cmd) }) @@ -162,25 +163,25 @@ var _ = Describe("controller", Ordered, func() { } EventuallyWithOffset(1, verifyControllerUp, time.Minute, time.Second).Should(Succeed()) - By("creating a DatabaseMySQLProvider resource") - cmd = exec.Command("kubectl", "apply", "-f", "config/samples/crd_v1alpha1_databasemysqlprovider.yaml") + By("creating a RelationalDatabaseProvider resource") + cmd = exec.Command("kubectl", "apply", "-f", "config/samples/crd_v1alpha1_relationaldatabaseprovider_mysql.yaml") _, err = utils.Run(cmd) ExpectWithOffset(1, err).NotTo(HaveOccurred()) - By("validating that the DatabaseMySQLProvider resource is created") + By("validating that the RelationalDatabaseProvider resource is created") cmd = exec.Command( "kubectl", "wait", "--for=condition=Ready", - "databasemysqlprovider", - "databasemysqlprovider-sample", + "relationaldatabaseprovider", + "relationaldatabaseprovider-mysql-sample", "--timeout=60s", ) _, err = utils.Run(cmd) ExpectWithOffset(1, err).NotTo(HaveOccurred()) By("creating a DatabaseRequest resource") - cmd = exec.Command("kubectl", "apply", "-f", "config/samples/crd_v1alpha1_databaserequest.yaml") + cmd = exec.Command("kubectl", "apply", "-f", "config/samples/crd_v1alpha1_databaserequest_mysql.yaml") _, err = utils.Run(cmd) ExpectWithOffset(1, err).NotTo(HaveOccurred()) @@ -190,7 +191,7 @@ var _ = Describe("controller", Ordered, func() { "wait", "--for=condition=Ready", "databaserequest", - "databaserequest-sample", + "databaserequest-mysql-sample", "--timeout=60s", ) _, err = utils.Run(cmd) @@ -203,7 +204,7 @@ var _ = Describe("controller", Ordered, func() { "get", "service", "-n", "default", - "-l", "app.kubernetes.io/instance=databaserequest-sample", + "-l", "app.kubernetes.io/instance=databaserequest-mysql-sample", ) serviceOutput, err := utils.Run(cmd) ExpectWithOffset(1, err).NotTo(HaveOccurred()) @@ -217,7 +218,7 @@ var _ = Describe("controller", Ordered, func() { "get", "secret", "-n", "default", - "-l", "app.kubernetes.io/instance=databaserequest-sample", + "-l", "app.kubernetes.io/instance=databaserequest-mysql-sample", ) secretOutput, err := utils.Run(cmd) ExpectWithOffset(1, err).NotTo(HaveOccurred()) @@ -225,7 +226,7 @@ var _ = Describe("controller", Ordered, func() { ExpectWithOffset(1, secretNames).Should(HaveLen(2)) By("deleting the DatabaseRequest resource the database is getting deprovisioned") - cmd = exec.Command("kubectl", "delete", "databaserequest", "databaserequest-sample") + cmd = exec.Command("kubectl", "delete", "databaserequest", "databaserequest-mysql-sample") _, err = utils.Run(cmd) ExpectWithOffset(1, err).NotTo(HaveOccurred()) @@ -235,7 +236,7 @@ var _ = Describe("controller", Ordered, func() { "get", "service", "-n", "default", - "-l", "app.kubernetes.io/instance=databaserequest-sample", + "-l", "app.kubernetes.io/instance=databaserequest-mysql-sample", ) serviceOutput, err = utils.Run(cmd) ExpectWithOffset(1, err).NotTo(HaveOccurred()) @@ -248,7 +249,7 @@ var _ = Describe("controller", Ordered, func() { "get", "secret", "-n", "default", - "-l", "app.kubernetes.io/instance=databaserequest-sample", + "-l", "app.kubernetes.io/instance=databaserequest-mysql-sample", ) secretOutput, err = utils.Run(cmd) ExpectWithOffset(1, err).NotTo(HaveOccurred()) diff --git a/test/e2e/testdata/mysql-client-pod.yaml b/test/e2e/testdata/mysql-client-pod.yaml new file mode 100644 index 0000000..1fc3a17 --- /dev/null +++ b/test/e2e/testdata/mysql-client-pod.yaml @@ -0,0 +1,16 @@ +apiVersion: v1 +kind: Pod +metadata: + name: mysql-init-pod + namespace: mysql +spec: + restartPolicy: Never + containers: + - name: mysql-client + image: mysql:8 # change to mysql:5.7 if you want to test with MySQL 5.7 but remember + # that the MySQL 5.7 image is is not multi-arch and will not work on ARM64 + # out of the box + command: ["sh", "-c"] + args: + - | + mysql -h mysql-service.mysql -uroot -pe2e-test-password -e "CREATE DATABASE IF NOT EXISTS seed-database;" \ No newline at end of file diff --git a/test/e2e/testdata/seed-secret.yaml b/test/e2e/testdata/seed-secret.yaml new file mode 100644 index 0000000..8280836 --- /dev/null +++ b/test/e2e/testdata/seed-secret.yaml @@ -0,0 +1,18 @@ +apiVersion: v1 +kind: Secret +metadata: + name: mysql-seed-secret + namespace: default +type: Opaque +data: + #database: seed-database + database: c2VlZC1kYXRhYmFzZQ== + #password: seed-password + password: c2VlZC1wYXNzd29yZA== + #username: seed-username + username: c2VlZC11c2VybmFtZQ== + #host mysql-service.mysql + host: bXlzc2VydmljZS5teXNxbC5kYg== + #port 3306 + port: MzMwNg== +