From 872613b88fbc36f1db77646e589e59d81c7011a3 Mon Sep 17 00:00:00 2001 From: Matthew Christopher Date: Mon, 16 May 2022 12:10:30 -0700 Subject: [PATCH] Add support for MySQLUser - Supports password rotation (by updating secret in k8s). - Does not support AAD today, but can be expanded to do so in the future. --- .../dbformysql/v1beta1/groupversion_info.go | 27 + v2/api/dbformysql/v1beta1/user_types.go | 223 +++++++++ .../v1beta1/zz_generated.deepcopy.go | 168 +++++++ .../virtual_network_extensions.go | 4 +- .../samples/dbformysql/v1beta1_user.yaml | 29 ++ .../controllers/controller_resources.go | 59 ++- v2/internal/controllers/generic_controller.go | 1 + v2/internal/controllers/indexers.go | 27 + .../azure_generic_arm_reconciler_instance.go | 41 +- v2/internal/reconcilers/common.go | 26 + .../mysql/mysql_user_reconciler.go | 279 +++++++++++ v2/internal/util/mysql/mysql.go | 107 ++++ v2/internal/util/mysql/privilege.go | 262 ++++++++++ v2/internal/util/mysql/privilege_test.go | 84 ++++ v2/test/mysql_secret_update_test.go | 157 ------ v2/test/mysql_test.go | 462 ++++++++++++++++++ 16 files changed, 1755 insertions(+), 201 deletions(-) create mode 100644 v2/api/dbformysql/v1beta1/groupversion_info.go create mode 100644 v2/api/dbformysql/v1beta1/user_types.go create mode 100644 v2/api/dbformysql/v1beta1/zz_generated.deepcopy.go create mode 100644 v2/config/samples/dbformysql/v1beta1_user.yaml create mode 100644 v2/internal/controllers/indexers.go create mode 100644 v2/internal/reconcilers/mysql/mysql_user_reconciler.go create mode 100644 v2/internal/util/mysql/mysql.go create mode 100644 v2/internal/util/mysql/privilege.go create mode 100644 v2/internal/util/mysql/privilege_test.go delete mode 100644 v2/test/mysql_secret_update_test.go create mode 100644 v2/test/mysql_test.go diff --git a/v2/api/dbformysql/v1beta1/groupversion_info.go b/v2/api/dbformysql/v1beta1/groupversion_info.go new file mode 100644 index 00000000000..1c8effe0204 --- /dev/null +++ b/v2/api/dbformysql/v1beta1/groupversion_info.go @@ -0,0 +1,27 @@ +/* +Copyright (c) Microsoft Corporation. +Licensed under the MIT license. +*/ + +// Package v1beta1 contains API Schema definitions for dbformysql data plane APIs +// +kubebuilder:object:generate=true +// All object properties are optional by default, this will be overridden when needed: +// +kubebuilder:validation:Optional +// +groupName=dbformysql.azure.com +package v1beta1 + +import ( + "k8s.io/apimachinery/pkg/runtime/schema" + "sigs.k8s.io/controller-runtime/pkg/scheme" +) + +var ( + // GroupVersion is group version used to register these objects + GroupVersion = schema.GroupVersion{Group: "dbformysql.azure.com", Version: "v1beta1"} + + // SchemeBuilder is used to add go types to the GroupVersionKind scheme + SchemeBuilder = &scheme.Builder{GroupVersion: GroupVersion} + + // AddToScheme adds the types in this group-version to the given scheme. + AddToScheme = SchemeBuilder.AddToScheme +) diff --git a/v2/api/dbformysql/v1beta1/user_types.go b/v2/api/dbformysql/v1beta1/user_types.go new file mode 100644 index 00000000000..47eace69788 --- /dev/null +++ b/v2/api/dbformysql/v1beta1/user_types.go @@ -0,0 +1,223 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +package v1beta1 + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + kerrors "k8s.io/apimachinery/pkg/util/errors" + "sigs.k8s.io/controller-runtime/pkg/webhook/admission" + + "github.com/Azure/azure-service-operator/v2/pkg/genruntime" + "github.com/Azure/azure-service-operator/v2/pkg/genruntime/conditions" +) + +// +kubebuilder:rbac:groups=dbformysql.azure.com,resources=users,verbs=get;list;watch;create;update;patch;delete +// +kubebuilder:rbac:groups=dbformysql.azure.com,resources={users/status,users/finalizers},verbs=get;update;patch + +// +kubebuilder:object:root=true +// +kubebuilder:subresource:status +// +kubebuilder:printcolumn:name="Ready",type="string",JSONPath=".status.conditions[?(@.type=='Ready')].status" +// +kubebuilder:printcolumn:name="Severity",type="string",JSONPath=".status.conditions[?(@.type=='Ready')].severity" +// +kubebuilder:printcolumn:name="Reason",type="string",JSONPath=".status.conditions[?(@.type=='Ready')].reason" +// +kubebuilder:printcolumn:name="Message",type="string",JSONPath=".status.conditions[?(@.type=='Ready')].message" +// +kubebuilder:storageversion +// User is a MySQL user +type User struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + Spec UserSpec `json:"spec,omitempty"` + Status UserStatus `json:"status,omitempty"` +} + +var _ conditions.Conditioner = &User{} + +// GetConditions returns the conditions of the resource +func (user *User) GetConditions() conditions.Conditions { + return user.Status.Conditions +} + +// SetConditions sets the conditions on the resource status +func (user *User) SetConditions(conditions conditions.Conditions) { + user.Status.Conditions = conditions +} + +// +kubebuilder:webhook:path=/mutate-dbformysql-azure-com-v1beta1-user,mutating=true,sideEffects=None,matchPolicy=Exact,failurePolicy=fail,groups=dbformysql.azure.com,resources=users,verbs=create;update,versions=v1beta1,name=default.v1beta1.users.dbformysql.azure.com,admissionReviewVersions=v1beta1 + +var _ admission.Defaulter = &User{} + +// Default applies defaults to the FlexibleServer resource +func (user *User) Default() { + user.defaultImpl() + var temp interface{} = user + if runtimeDefaulter, ok := temp.(genruntime.Defaulter); ok { + runtimeDefaulter.CustomDefault() + } +} + +// defaultAzureName defaults the Azure name of the resource to the Kubernetes name +func (user *User) defaultAzureName() { + if user.Spec.AzureName == "" { + user.Spec.AzureName = user.Name + } +} + +// defaultImpl applies the code generated defaults to the FlexibleServer resource +func (user *User) defaultImpl() { user.defaultAzureName() } + +var _ genruntime.ARMOwned = &User{} + +// AzureName returns the Azure name of the resource +func (user *User) AzureName() string { + return user.Spec.AzureName +} + +// Owner returns the ResourceReference of the owner, or nil if there is no owner +func (user *User) Owner() *genruntime.ResourceReference { + group, kind := genruntime.LookupOwnerGroupKind(user.Spec) + return &genruntime.ResourceReference{ + Group: group, + Kind: kind, + Name: user.Spec.Owner.Name, + } +} + +// +kubebuilder:webhook:path=/validate-dbformysql-azure-com-v1beta1-user,mutating=false,sideEffects=None,matchPolicy=Exact,failurePolicy=fail,groups=dbformysql.azure.com,resources=users,verbs=create;update,versions=v1beta1,name=validate.v1beta1.users.dbformysql.azure.com,admissionReviewVersions=v1beta1 + +var _ admission.Validator = &User{} + +// ValidateCreate validates the creation of the resource +func (user *User) ValidateCreate() error { + validations := user.createValidations() + var temp interface{} = user + if runtimeValidator, ok := temp.(genruntime.Validator); ok { + validations = append(validations, runtimeValidator.CreateValidations()...) + } + var errs []error + for _, validation := range validations { + err := validation() + if err != nil { + errs = append(errs, err) + } + } + return kerrors.NewAggregate(errs) +} + +// ValidateDelete validates the deletion of the resource +func (user *User) ValidateDelete() error { + validations := user.deleteValidations() + var temp interface{} = user + if runtimeValidator, ok := temp.(genruntime.Validator); ok { + validations = append(validations, runtimeValidator.DeleteValidations()...) + } + var errs []error + for _, validation := range validations { + err := validation() + if err != nil { + errs = append(errs, err) + } + } + return kerrors.NewAggregate(errs) +} + +// ValidateUpdate validates an update of the resource +func (user *User) ValidateUpdate(old runtime.Object) error { + validations := user.updateValidations() + var temp interface{} = user + if runtimeValidator, ok := temp.(genruntime.Validator); ok { + validations = append(validations, runtimeValidator.UpdateValidations()...) + } + var errs []error + for _, validation := range validations { + err := validation(old) + if err != nil { + errs = append(errs, err) + } + } + return kerrors.NewAggregate(errs) +} + +// createValidations validates the creation of the resource +func (user *User) createValidations() []func() error { + return nil +} + +// deleteValidations validates the deletion of the resource +func (user *User) deleteValidations() []func() error { + return nil +} + +// updateValidations validates the update of the resource +func (user *User) updateValidations() []func(old runtime.Object) error { + return nil +} + +// +kubebuilder:object:root=true +type UserList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []User `json:"items"` +} + +type UserSpec struct { + //AzureName: The name of the resource in Azure. This is often the same as the name of the resource in Kubernetes but it + //doesn't have to be. + AzureName string `json:"azureName,omitempty"` + + // +kubebuilder:validation:Required + //Owner: The owner of the resource. The owner controls where the resource goes when it is deployed. The owner also + //controls the resources lifecycle. When the owner is deleted the resource will also be deleted. Owner is expected to be a + //reference to a dbformysql.azure.com/FlexibleServer resource + Owner *genruntime.KnownResourceReference `group:"dbformysql.azure.com" json:"owner,omitempty" kind:"FlexibleServer"` + + // Hostname is the host the user will connect from. If omitted, the default is to allow connection from any hostname. + Hostname string `json:"hostname,omitempty"` + + // The server-level roles assigned to the user. + // Privileges include the following: RELOAD, PROCESS, SHOW + // DATABASES, REPLICATION SLAVE, REPLICATION CLIENT, CREATE USER + Privileges []string `json:"privileges,omitempty"` + + // The database-level roles assigned to the user (keyed by + // database name). Privileges include the following: 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 + DatabasePrivileges map[string][]string `json:"databasePrivileges,omitempty"` + + // TODO: Note this is required right now but will move to be optional in the future when we have AAD support + // +kubebuilder:validation:Required + // LocalUser contains details for creating a standard (non-aad) MySQL User + LocalUser *LocalUserSpec `json:"localUser,omitempty"` +} + +// OriginalVersion returns the original API version used to create the resource. +func (userSpec *UserSpec) OriginalVersion() string { + return GroupVersion.Version +} + +// SetAzureName sets the Azure name of the resource +func (userSpec *UserSpec) SetAzureName(azureName string) { userSpec.AzureName = azureName } + +type LocalUserSpec struct { + // +kubebuilder:validation:Required + // ServerAdminUsername is the user name of the Server administrator + ServerAdminUsername string `json:"serverAdminUsername,omitempty"` + + // +kubebuilder:validation:Required + // ServerAdminPassword is a reference to a secret containing the servers administrator password + ServerAdminPassword *genruntime.SecretReference `json:"serverAdminPassword,omitempty"` + + // +kubebuilder:validation:Required + // Password is the password to use for the user + Password *genruntime.SecretReference `json:"password,omitempty"` +} + +type UserStatus struct { + //Conditions: The observed state of the resource + Conditions []conditions.Condition `json:"conditions,omitempty"` +} + +func init() { + SchemeBuilder.Register(&User{}, &UserList{}) +} diff --git a/v2/api/dbformysql/v1beta1/zz_generated.deepcopy.go b/v2/api/dbformysql/v1beta1/zz_generated.deepcopy.go new file mode 100644 index 00000000000..178b1980178 --- /dev/null +++ b/v2/api/dbformysql/v1beta1/zz_generated.deepcopy.go @@ -0,0 +1,168 @@ +//go:build !ignore_autogenerated +// +build !ignore_autogenerated + +/* +Copyright (c) Microsoft Corporation. +Licensed under the MIT license. +*/ + +// Code generated by controller-gen. DO NOT EDIT. + +package v1beta1 + +import ( + "github.com/Azure/azure-service-operator/v2/pkg/genruntime" + "github.com/Azure/azure-service-operator/v2/pkg/genruntime/conditions" + "k8s.io/apimachinery/pkg/runtime" +) + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *LocalUserSpec) DeepCopyInto(out *LocalUserSpec) { + *out = *in + if in.ServerAdminPassword != nil { + in, out := &in.ServerAdminPassword, &out.ServerAdminPassword + *out = new(genruntime.SecretReference) + **out = **in + } + if in.Password != nil { + in, out := &in.Password, &out.Password + *out = new(genruntime.SecretReference) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new LocalUserSpec. +func (in *LocalUserSpec) DeepCopy() *LocalUserSpec { + if in == nil { + return nil + } + out := new(LocalUserSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *User) DeepCopyInto(out *User) { + *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 User. +func (in *User) DeepCopy() *User { + if in == nil { + return nil + } + out := new(User) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *User) 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 *UserList) DeepCopyInto(out *UserList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]User, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new UserList. +func (in *UserList) DeepCopy() *UserList { + if in == nil { + return nil + } + out := new(UserList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *UserList) 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 *UserSpec) DeepCopyInto(out *UserSpec) { + *out = *in + if in.Owner != nil { + in, out := &in.Owner, &out.Owner + *out = new(genruntime.KnownResourceReference) + **out = **in + } + if in.Privileges != nil { + in, out := &in.Privileges, &out.Privileges + *out = make([]string, len(*in)) + copy(*out, *in) + } + if in.DatabasePrivileges != nil { + in, out := &in.DatabasePrivileges, &out.DatabasePrivileges + *out = make(map[string][]string, len(*in)) + for key, val := range *in { + var outVal []string + if val == nil { + (*out)[key] = nil + } else { + in, out := &val, &outVal + *out = make([]string, len(*in)) + copy(*out, *in) + } + (*out)[key] = outVal + } + } + if in.LocalUser != nil { + in, out := &in.LocalUser, &out.LocalUser + *out = new(LocalUserSpec) + (*in).DeepCopyInto(*out) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new UserSpec. +func (in *UserSpec) DeepCopy() *UserSpec { + if in == nil { + return nil + } + out := new(UserSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *UserStatus) DeepCopyInto(out *UserStatus) { + *out = *in + if in.Conditions != nil { + in, out := &in.Conditions, &out.Conditions + *out = make([]conditions.Condition, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new UserStatus. +func (in *UserStatus) DeepCopy() *UserStatus { + if in == nil { + return nil + } + out := new(UserStatus) + in.DeepCopyInto(out) + return out +} diff --git a/v2/api/network/customizations/virtual_network_extensions.go b/v2/api/network/customizations/virtual_network_extensions.go index 550f77c3bbd..4a3dca3f037 100644 --- a/v2/api/network/customizations/virtual_network_extensions.go +++ b/v2/api/network/customizations/virtual_network_extensions.go @@ -15,7 +15,7 @@ import ( network "github.com/Azure/azure-service-operator/v2/api/network/v1beta20201101storage" . "github.com/Azure/azure-service-operator/v2/internal/logging" - "github.com/Azure/azure-service-operator/v2/internal/reconcilers/arm" + "github.com/Azure/azure-service-operator/v2/internal/reconcilers" "github.com/Azure/azure-service-operator/v2/internal/resolver" "github.com/Azure/azure-service-operator/v2/internal/util/kubeclient" "github.com/Azure/azure-service-operator/v2/pkg/genruntime" @@ -101,7 +101,7 @@ func transformToARM( _, resolvedDetails, err := resolver.ResolveAll(ctx, obj) if err != nil { - return nil, arm.ClassifyResolverError(err) + return nil, reconcilers.ClassifyResolverError(err) } armSpec, err := armTransformer.ConvertToARM(resolvedDetails) diff --git a/v2/config/samples/dbformysql/v1beta1_user.yaml b/v2/config/samples/dbformysql/v1beta1_user.yaml new file mode 100644 index 00000000000..cfd18c2f18c --- /dev/null +++ b/v2/config/samples/dbformysql/v1beta1_user.yaml @@ -0,0 +1,29 @@ +apiVersion: dbformysql.azure.com/v1beta1 +kind: User +metadata: + name: sampleuser + namespace: default +spec: + owner: + name: samplemysql + # Specify a list of server-level privileges. Privileges + # include the following: RELOAD, PROCESS, SHOW DATABASES, + # REPLICATION SLAVE, REPLICATION CLIENT, CREATE USER + privileges: + - PROCESS + - CREATE USER + databasePrivileges: + mysqldatabase-sample: + # Privileges include the following: + # 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 + - SELECT + localUser: + serverAdminUsername: admin + serverAdminPassword: + name: server-admin-pw + key: password + password: + name: sampleuser-password + key: password diff --git a/v2/internal/controllers/controller_resources.go b/v2/internal/controllers/controller_resources.go index 0122b0ce7b7..67351ec43f3 100644 --- a/v2/internal/controllers/controller_resources.go +++ b/v2/internal/controllers/controller_resources.go @@ -17,6 +17,7 @@ import ( ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/client/apiutil" ctrlconversion "sigs.k8s.io/controller-runtime/pkg/conversion" + "sigs.k8s.io/controller-runtime/pkg/source" "github.com/go-logr/logr" corev1 "k8s.io/api/core/v1" @@ -26,11 +27,13 @@ import ( "sigs.k8s.io/controller-runtime/pkg/handler" "sigs.k8s.io/controller-runtime/pkg/reconcile" + mysql "github.com/Azure/azure-service-operator/v2/api/dbformysql/v1beta1" networkstorage "github.com/Azure/azure-service-operator/v2/api/network/v1beta20201101storage" resourcesalpha "github.com/Azure/azure-service-operator/v2/api/resources/v1alpha1api20200601" resourcesbeta "github.com/Azure/azure-service-operator/v2/api/resources/v1beta20200601" . "github.com/Azure/azure-service-operator/v2/internal/logging" "github.com/Azure/azure-service-operator/v2/internal/reconcilers/arm" + mysqlreconciler "github.com/Azure/azure-service-operator/v2/internal/reconcilers/mysql" "github.com/Azure/azure-service-operator/v2/internal/reflecthelpers" "github.com/Azure/azure-service-operator/v2/internal/resolver" "github.com/Azure/azure-service-operator/v2/internal/util/kubeclient" @@ -47,6 +50,51 @@ func GetKnownStorageTypes( options Options) ([]*registration.StorageType, error) { resourceResolver := resolver.NewResolver(kubeClient) + knownStorageTypes, err := getGeneratedStorageTypes(mgr, armClientFactory, kubeClient, resourceResolver, positiveConditions, options) + if err != nil { + return nil, err + } + + knownStorageTypes = append( + knownStorageTypes, + ®istration.StorageType{ + Obj: &mysql.User{}, + Reconciler: mysqlreconciler.NewMySQLUserReconciler( + kubeClient, + resourceResolver, + positiveConditions, + options.Config), + Indexes: []registration.Index{ + { + Key: ".spec.localUser.password", + Func: indexMySQLUserPassword, + }, + }, + Watches: []registration.Watch{ + { + Src: &source.Kind{Type: &corev1.Secret{}}, + MakeEventHandler: watchSecretsFactory([]string{".spec.localUser.password"}, &mysql.UserList{}), + }, + }, + }) + + for _, t := range knownStorageTypes { + err := augmentWithControllerName(t) + if err != nil { + return nil, err + } + } + + return knownStorageTypes, nil +} + +func getGeneratedStorageTypes( + mgr ctrl.Manager, + armClientFactory arm.ARMClientFactory, + kubeClient kubeclient.Client, + resourceResolver *resolver.Resolver, + positiveConditions *conditions.PositiveConditionBuilder, + options Options) ([]*registration.StorageType, error) { knownStorageTypes := getKnownStorageTypes() knownStorageTypes = append( @@ -105,13 +153,6 @@ func GetKnownStorageTypes( } } - for _, t := range knownStorageTypes { - err = augmentWithControllerName(t) - if err != nil { - return nil, err - } - } - return knownStorageTypes, nil } @@ -171,7 +212,8 @@ func GetKnownTypes() []client.Object { knownTypes = append( knownTypes, &resourcesalpha.ResourceGroup{}, - &resourcesbeta.ResourceGroup{}) + &resourcesbeta.ResourceGroup{}, + &mysql.User{}) return knownTypes } @@ -180,6 +222,7 @@ func CreateScheme() *runtime.Scheme { scheme := createScheme() _ = resourcesalpha.AddToScheme(scheme) _ = resourcesbeta.AddToScheme(scheme) + _ = mysql.AddToScheme(scheme) return scheme } diff --git a/v2/internal/controllers/generic_controller.go b/v2/internal/controllers/generic_controller.go index 15d619c44f1..a7ffbdeaffa 100644 --- a/v2/internal/controllers/generic_controller.go +++ b/v2/internal/controllers/generic_controller.go @@ -243,6 +243,7 @@ func (gr *GenericReconciler) Reconcile(ctx context.Context, req ctrl.Request) (c result, err := gr.Reconciler.Reconcile(ctx, log, gr.Recorder, metaObj) if readyErr, ok := conditions.AsReadyConditionImpactingError(err); ok { + log.Error(readyErr, "Encountered error impacting Ready condition") err = gr.WriteReadyConditionError(ctx, metaObj, readyErr) } diff --git a/v2/internal/controllers/indexers.go b/v2/internal/controllers/indexers.go new file mode 100644 index 00000000000..68ba3973f24 --- /dev/null +++ b/v2/internal/controllers/indexers.go @@ -0,0 +1,27 @@ +/* +Copyright (c) Microsoft Corporation. +Licensed under the MIT license. +*/ + +package controllers + +import ( + "sigs.k8s.io/controller-runtime/pkg/client" + + mysql "github.com/Azure/azure-service-operator/v2/api/dbformysql/v1beta1" +) + +// indexMySQLUserPassword an index function for mysql user passwords +func indexMySQLUserPassword(rawObj client.Object) []string { + obj, ok := rawObj.(*mysql.User) + if !ok { + return nil + } + if obj.Spec.LocalUser == nil { + return nil + } + if obj.Spec.LocalUser.Password == nil { + return nil + } + return []string{obj.Spec.LocalUser.Password.Name} +} diff --git a/v2/internal/reconcilers/arm/azure_generic_arm_reconciler_instance.go b/v2/internal/reconcilers/arm/azure_generic_arm_reconciler_instance.go index 60316af5aea..d68add1c0c2 100644 --- a/v2/internal/reconcilers/arm/azure_generic_arm_reconciler_instance.go +++ b/v2/internal/reconcilers/arm/azure_generic_arm_reconciler_instance.go @@ -217,13 +217,13 @@ func (r *azureDeploymentReconcilerInstance) StartDeleteOfResource(ctx context.Co hasFinalizer := controllerutil.ContainsFinalizer(r.Obj, reconcilers.GenericControllerFinalizer) resourceID := genruntime.GetResourceIDOrDefault(r.Obj) if resourceID == "" || !hasFinalizer { - return ctrl.Result{}, r.deleteResourceSucceeded(ctx) + return ctrl.Result{}, r.RemoveResourceFinalizer(ctx, r.Log, r.Obj) } reconcilePolicy := reconcilers.GetReconcilePolicy(r.Obj, r.Log) if !reconcilePolicy.AllowsDelete() { r.Log.V(Info).Info("Bypassing delete of resource in Azure due to policy", "policy", reconcilePolicy) - return ctrl.Result{}, r.deleteResourceSucceeded(ctx) + return ctrl.Result{}, r.RemoveResourceFinalizer(ctx, r.Log, r.Obj) } // Check that this objects owner still exists @@ -232,7 +232,7 @@ func (r *azureDeploymentReconcilerInstance) StartDeleteOfResource(ctx context.Co if err != nil { var typedErr *resolver.ReferenceNotFound if errors.As(err, &typedErr) { - return ctrl.Result{}, r.deleteResourceSucceeded(ctx) + return ctrl.Result{}, r.RemoveResourceFinalizer(ctx, r.Log, r.Obj) } } @@ -261,7 +261,7 @@ func (r *azureDeploymentReconcilerInstance) MonitorDelete(ctx context.Context) ( hasFinalizer := controllerutil.ContainsFinalizer(r.Obj, reconcilers.GenericControllerFinalizer) if !hasFinalizer { r.Log.V(Status).Info("Resource no longer has finalizer, moving deletion to success") - return ctrl.Result{}, r.deleteResourceSucceeded(ctx) + return ctrl.Result{}, r.RemoveResourceFinalizer(ctx, r.Log, r.Obj) } msg := "Continue monitoring deletion" @@ -289,8 +289,8 @@ func (r *azureDeploymentReconcilerInstance) MonitorDelete(ctx context.Context) ( return ctrl.Result{Requeue: true}, nil } - // TODO: Transfer the below into controller? - err = r.deleteResourceSucceeded(ctx) + // TODO: Transfer the below into the generic controller? + err = r.RemoveResourceFinalizer(ctx, r.Log, r.Obj) return ctrl.Result{}, err } @@ -523,22 +523,6 @@ func (r *azureDeploymentReconcilerInstance) updateStatus(ctx context.Context) er return nil } -// TODO: it's not clear if we want to reserve updates of the resource to the controller itself (and keep KubeClient out of the azureDeploymentReconcilerInstance) -func (r *azureDeploymentReconcilerInstance) deleteResourceSucceeded(ctx context.Context) error { - controllerutil.RemoveFinalizer(r.Obj, reconcilers.GenericControllerFinalizer) - err := r.CommitUpdate(ctx, r.Log, r.Obj) - - // We must also ignore conflict here because updating a resource that - // doesn't exist returns conflict unfortunately: https://github.com/kubernetes/kubernetes/issues/89985 - err = reconcilers.IgnoreNotFoundAndConflict(err) - if err != nil { - return err - } - - r.Log.V(Status).Info("Deleted resource") - return nil -} - // saveAzureSecrets retrieves secrets from Azure and saves them to Kubernetes. // If there are no secrets to save this method is a no-op. func (r *azureDeploymentReconcilerInstance) saveAzureSecrets(ctx context.Context) error { @@ -605,7 +589,7 @@ func ConvertToARMResourceImpl( resourceHierarchy, resolvedDetails, err := resolver.ResolveAll(ctx, metaObject) if err != nil { - return nil, ClassifyResolverError(err) + return nil, reconcilers.ClassifyResolverError(err) } armSpec, err := armTransformer.ConvertToARM(resolvedDetails) @@ -626,14 +610,3 @@ func ConvertToARMResourceImpl( result := genruntime.NewARMResource(typedArmSpec, nil, armID) return result, nil } - -func ClassifyResolverError(err error) error { - // If it's specifically secret not found, say so - var typedErr *resolver.SecretNotFound - if errors.As(err, &typedErr) { - return conditions.NewReadyConditionImpactingError(err, conditions.ConditionSeverityWarning, conditions.ReasonSecretNotFound) - } - // Everything else is ReferenceNotFound. This is maybe a bit of a lie but secrets are also references and we want to make sure - // everything is classified as something, so for now it's good enough. - return conditions.NewReadyConditionImpactingError(err, conditions.ConditionSeverityWarning, conditions.ReasonReferenceNotFound) -} diff --git a/v2/internal/reconcilers/common.go b/v2/internal/reconcilers/common.go index 1aee711d58d..cbc6cb3b6c3 100644 --- a/v2/internal/reconcilers/common.go +++ b/v2/internal/reconcilers/common.go @@ -189,3 +189,29 @@ func (r *ReconcilerCommon) CommitUpdate(ctx context.Context, log logr.Logger, ob LogObj(log, "updated resource in etcd", obj) return nil } + +func (r *ReconcilerCommon) RemoveResourceFinalizer(ctx context.Context, log logr.Logger, obj genruntime.MetaObject) error { + controllerutil.RemoveFinalizer(obj, GenericControllerFinalizer) + err := r.CommitUpdate(ctx, log, obj) + + // We must also ignore conflict here because updating a resource that + // doesn't exist returns conflict unfortunately: https://github.com/kubernetes/kubernetes/issues/89985 + err = IgnoreNotFoundAndConflict(err) + if err != nil { + return err + } + + log.V(Status).Info("Deleted resource") + return nil +} + +func ClassifyResolverError(err error) error { + // If it's specifically secret not found, say so + var typedErr *resolver.SecretNotFound + if errors.As(err, &typedErr) { + return conditions.NewReadyConditionImpactingError(err, conditions.ConditionSeverityWarning, conditions.ReasonSecretNotFound) + } + // Everything else is ReferenceNotFound. This is maybe a bit of a lie but secrets are also references and we want to make sure + // everything is classified as something, so for now it's good enough. + return conditions.NewReadyConditionImpactingError(err, conditions.ConditionSeverityWarning, conditions.ReasonReferenceNotFound) +} diff --git a/v2/internal/reconcilers/mysql/mysql_user_reconciler.go b/v2/internal/reconcilers/mysql/mysql_user_reconciler.go new file mode 100644 index 00000000000..465221f6b66 --- /dev/null +++ b/v2/internal/reconcilers/mysql/mysql_user_reconciler.go @@ -0,0 +1,279 @@ +/* +Copyright (c) Microsoft Corporation. +Licensed under the MIT license. +*/ + +package arm + +import ( + "context" + "database/sql" + + "github.com/go-logr/logr" + _ "github.com/go-sql-driver/mysql" //mysql driver + "github.com/pkg/errors" + "k8s.io/client-go/tools/record" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" + ctrlconversion "sigs.k8s.io/controller-runtime/pkg/conversion" + + asomysql "github.com/Azure/azure-service-operator/v2/api/dbformysql/v1beta1" + dbformysql "github.com/Azure/azure-service-operator/v2/api/dbformysql/v1beta20210501storage" + "github.com/Azure/azure-service-operator/v2/internal/config" + . "github.com/Azure/azure-service-operator/v2/internal/logging" + "github.com/Azure/azure-service-operator/v2/internal/reconcilers" + "github.com/Azure/azure-service-operator/v2/internal/resolver" + "github.com/Azure/azure-service-operator/v2/internal/util/kubeclient" + mysqlutil "github.com/Azure/azure-service-operator/v2/internal/util/mysql" + "github.com/Azure/azure-service-operator/v2/pkg/genruntime" + "github.com/Azure/azure-service-operator/v2/pkg/genruntime/conditions" +) + +var _ genruntime.Reconciler = &MySQLUserReconciler{} + +type MySQLUserReconciler struct { + reconcilers.ARMOwnedResourceReconcilerCommon + KubeClient kubeclient.Client + ResourceResolver *resolver.Resolver + PositiveConditions *conditions.PositiveConditionBuilder + Config config.Values +} + +func NewMySQLUserReconciler( + kubeClient kubeclient.Client, + resourceResolver *resolver.Resolver, + positiveConditions *conditions.PositiveConditionBuilder, + cfg config.Values) *MySQLUserReconciler { + + return &MySQLUserReconciler{ + KubeClient: kubeClient, + ResourceResolver: resourceResolver, + PositiveConditions: positiveConditions, + Config: cfg, + ARMOwnedResourceReconcilerCommon: reconcilers.ARMOwnedResourceReconcilerCommon{ + ResourceResolver: resourceResolver, + ReconcilerCommon: reconcilers.ReconcilerCommon{ + KubeClient: kubeClient, + }, + }, + } +} + +func (r *MySQLUserReconciler) Reconcile( + ctx context.Context, + log logr.Logger, + eventRecorder record.EventRecorder, + obj genruntime.MetaObject) (ctrl.Result, error) { + + typedObj, ok := obj.(*asomysql.User) + if !ok { + return ctrl.Result{}, errors.Errorf("cannot modify resource that is not of type *asomysql.User. Type is %T", obj) + } + + // Augment Log + log = log.WithValues("azureName", typedObj.AzureName()) + + var result ctrl.Result + var err error + if !obj.GetDeletionTimestamp().IsZero() { + result, err = r.Delete(ctx, log, typedObj) + } else { + result, err = r.CreateOrUpdate(ctx, log, typedObj) + } + + if err != nil { + return ctrl.Result{}, err + } + + return result, nil +} + +func (r *MySQLUserReconciler) Delete(ctx context.Context, log logr.Logger, user *asomysql.User) (ctrl.Result, error) { + // TODO: A lot of this is duplicated. See https://azure.github.io/azure-service-operator/design/reconcile-interface for a proposal to fix that + log.V(Status).Info("Starting delete of resource") + + // If we have no resourceID to begin with, or no finalizer, the Azure resource was never created + hasFinalizer := controllerutil.ContainsFinalizer(user, reconcilers.GenericControllerFinalizer) + if !hasFinalizer { + return ctrl.Result{}, r.RemoveResourceFinalizer(ctx, log, user) + } + + reconcilePolicy := reconcilers.GetReconcilePolicy(user, log) + if !reconcilePolicy.AllowsDelete() { + log.V(Info).Info("Bypassing delete of resource due to policy", "policy", reconcilePolicy) + return ctrl.Result{}, r.RemoveResourceFinalizer(ctx, log, user) + } + + // Check that this objects owner still exists + // This is an optimization to avoid making excess requests to Azure. + _, err := r.ResourceResolver.ResolveOwner(ctx, user) + if err != nil { + var typedErr *resolver.ReferenceNotFound + if errors.As(err, &typedErr) { + return ctrl.Result{}, r.RemoveResourceFinalizer(ctx, log, user) + } + } + + secrets, err := r.ResourceResolver.ResolveResourceSecretReferences(ctx, user) + if err != nil { + return ctrl.Result{}, err + } + + db, err := r.connectToDB(ctx, log, user, secrets) + if err != nil { + return ctrl.Result{}, err + } + defer db.Close() + + // TODO: There's still probably some ways that this user can be deleted but that we don't detect (and + // TODO: so might cause an error triggering the resource to get stuck). + // TODO: We check for owner not existing above, but cases where the server is in the process of being + // TODO: deleted (or all system tables have been wiped?) might also exist... + err = mysqlutil.DropUser(ctx, db, user.Spec.AzureName) + if err != nil { + return ctrl.Result{}, err + } + + err = r.RemoveResourceFinalizer(ctx, log, user) + if err != nil { + return ctrl.Result{}, err + } + + return ctrl.Result{}, nil +} + +func (r *MySQLUserReconciler) CreateOrUpdate(ctx context.Context, log logr.Logger, user *asomysql.User) (ctrl.Result, error) { + if r.NeedToClaimResource(user) { + // TODO: Make this synchronous for both ARM resources and here + // TODO: Will do this in a follow-up PR + _, err := r.ClaimResource(ctx, log, user) + if err != nil { + return ctrl.Result{}, err + } + } + + // Resolve the secrets + secrets, err := r.ResourceResolver.ResolveResourceSecretReferences(ctx, user) + if err != nil { + return ctrl.Result{}, reconcilers.ClassifyResolverError(err) + } + + db, err := r.connectToDB(ctx, log, user, secrets) + if err != nil { + return ctrl.Result{}, err + } + defer db.Close() + + // TODO: A lot of this is duplicated. See https://azure.github.io/azure-service-operator/design/reconcile-interface for a proposal to fix that + reconcilePolicy := reconcilers.GetReconcilePolicy(user, log) + if !reconcilePolicy.AllowsModify() { + return r.handleSkipReconcile(ctx, db, log, user) + } + + log.V(Status).Info("Creating MySQL user") + + password, err := secrets.LookupSecretFromPtr(user.Spec.LocalUser.Password) + if err != nil { + return ctrl.Result{}, errors.Wrap(err, "failed to look up .spec.localUser.Password") + } + + // Create or update the user. Note that this updates password if it has changed + username := user.Spec.AzureName + err = mysqlutil.CreateOrUpdateUser(ctx, db, user.Spec.AzureName, user.Spec.Hostname, password) + if err != nil { + return ctrl.Result{}, errors.Wrap(err, "failed to create user") + } + + // Ensure that the privileges are set + err = mysqlutil.ReconcileUserServerPrivileges(ctx, db, username, user.Spec.Hostname, user.Spec.Privileges) + if err != nil { + return ctrl.Result{}, errors.Wrap(err, "ensuring server roles") + } + + err = mysqlutil.ReconcileUserDatabasePrivileges(ctx, db, username, user.Spec.Hostname, user.Spec.DatabasePrivileges) + if err != nil { + return ctrl.Result{}, errors.Wrap(err, "ensuring database roles") + } + + log.V(Status).Info("Successfully reconciled MySQLUser") + + conditions.SetCondition(user, r.PositiveConditions.Ready.Succeeded(user.GetGeneration())) + err = r.CommitUpdate(ctx, log, user) + if err != nil { + return ctrl.Result{}, err + } + + return ctrl.Result{}, nil +} + +func (r *MySQLUserReconciler) connectToDB(ctx context.Context, log logr.Logger, user *asomysql.User, secrets genruntime.ResolvedSecrets) (*sql.DB, error) { + // Get the owner - at this point it must exist + owner, err := r.ResourceResolver.ResolveOwner(ctx, user) + if err != nil { + return nil, err + } + + flexibleServer, ok := owner.(*dbformysql.FlexibleServer) + if !ok { + return nil, errors.Errorf("owner was not type FlexibleServer, instead: %T", owner) + } + // Magical assertion to ensure that this is still the storage type + var _ ctrlconversion.Hub = &dbformysql.FlexibleServer{} + + if flexibleServer.Status.FullyQualifiedDomainName == nil { + // This possibly means that the server hasn't finished deploying yet + err = errors.Errorf("owning Flexibleserver %q '.status.fullyQualifiedDomainName' not set. Has the server been provisioned successfully?", flexibleServer.Name) + return nil, conditions.NewReadyConditionImpactingError(err, conditions.ConditionSeverityWarning, conditions.ReasonWaitingForOwner) + } + serverFQDN := *flexibleServer.Status.FullyQualifiedDomainName + + adminPassword, err := secrets.LookupSecretFromPtr(user.Spec.LocalUser.ServerAdminPassword) + if err != nil { + err = errors.Wrap(err, "failed to look up .spec.localUser.ServerAdminPassword") + err = conditions.NewReadyConditionImpactingError(err, conditions.ConditionSeverityWarning, conditions.ReasonSecretNotFound) + return nil, err + } + + // Admin User + adminUser := user.Spec.LocalUser.ServerAdminUsername + + // Connect to the DB + db, err := mysqlutil.ConnectToDB(ctx, serverFQDN, mysqlutil.SystemDatabase, mysqlutil.ServerPort, adminUser, adminPassword) + if err != nil { + return nil, errors.Wrapf( + err, + "failed to connect database. Server: %s, Database: %s, Port: %d, AdminUser: %s", + serverFQDN, + mysqlutil.SystemDatabase, + mysqlutil.ServerPort, + adminUser) + } + + return db, nil +} + +// TODO: A lot of this is duplicated. See https://azure.github.io/azure-service-operator/design/reconcile-interface for a proposal to fix that +func (r *MySQLUserReconciler) handleSkipReconcile(ctx context.Context, db *sql.DB, log logr.Logger, user *asomysql.User) (ctrl.Result, error) { + reconcilePolicy := reconcilers.GetReconcilePolicy(user, log) + log.V(Status).Info( + "Skipping creation of MySQLUser due to policy", + reconcilers.ReconcilePolicyAnnotation, reconcilePolicy) + + exists, err := mysqlutil.DoesUserExist(ctx, db, user.Spec.AzureName) + if err != nil { + return ctrl.Result{}, err + } + + if !exists { + err = errors.Errorf("user %s does not exist", user.Spec.AzureName) + err = conditions.NewReadyConditionImpactingError(err, conditions.ConditionSeverityWarning, conditions.ReasonAzureResourceNotFound) + return ctrl.Result{}, err + } + + conditions.SetCondition(user, r.PositiveConditions.Ready.Succeeded(user.GetGeneration())) + if err := r.CommitUpdate(ctx, log, user); err != nil { + return ctrl.Result{}, err + } + + return ctrl.Result{}, nil +} diff --git a/v2/internal/util/mysql/mysql.go b/v2/internal/util/mysql/mysql.go new file mode 100644 index 00000000000..d5daadd4f75 --- /dev/null +++ b/v2/internal/util/mysql/mysql.go @@ -0,0 +1,107 @@ +/* +Copyright (c) Microsoft Corporation. +Licensed under the MIT license. +*/ + +package mysql + +import ( + "context" + "database/sql" + "fmt" + "time" + + "github.com/go-sql-driver/mysql" + _ "github.com/go-sql-driver/mysql" //mysql drive link + "github.com/pkg/errors" +) + +// ServerPort is the default server port for sql server +const ServerPort = 3306 + +// DriverName is driver name for psqldb connection +const DriverName = "mysql" + +// SystemDatabase is the name of the system database in a MySQL server +// where users and privileges are stored (and which we can always +// assume will exist). +const SystemDatabase = "mysql" + +func ConnectToDB(ctx context.Context, serverAddress string, database string, port int, user string, password string) (*sql.DB, error) { + c := mysql.NewConfig() + c.Addr = fmt.Sprintf("%s:%d", serverAddress, port) + c.DBName = database + c.User = user + c.Passwd = password + c.TLSConfig = "true" + + // Set other options + c.InterpolateParams = true + c.Net = "tcp" + + db, err := sql.Open(DriverName, c.FormatDSN()) + if err != nil { + return db, err + } + db.SetConnMaxLifetime(1 * time.Minute) + db.SetMaxOpenConns(1) + db.SetMaxIdleConns(1) + + // We ping here to ensure that the connection is actually viable, as per + // https://github.com/go-sql-driver/mysql/wiki/Examples#a-word-on-sqlopen + err = db.PingContext(ctx) + if err != nil { + return db, errors.Wrapf(err, "error pinging the mysql db (%s:%d/%s)", serverAddress, port, database) + } + + return db, err +} + +func HostnameOrDefault(hostname string) string { + if hostname == "" { + hostname = "%" + } + + return hostname +} + +func CreateOrUpdateUser(ctx context.Context, db *sql.DB, username string, hostname string, password string) error { + hostname = HostnameOrDefault(hostname) + + // we call both CREATE and ALTER here so achieve an idempotent operation that also updates the password seamlessly + // if it has changed + + // TODO: Support aliasing: https://github.com/Azure/azure-service-operator/issues/1402 + statement := "CREATE USER IF NOT EXISTS ?@? IDENTIFIED BY ?" + _, err := db.ExecContext(ctx, statement, username, hostname, password) + if err != nil { + return errors.Wrapf(err, "failed to create user %s", username) + } + + // TODO: Support aliasing: https://github.com/Azure/azure-service-operator/issues/1402 + statement = "ALTER USER IF EXISTS ?@? IDENTIFIED BY ?" + _, err = db.ExecContext(ctx, statement, username, hostname, password) + if err != nil { + return errors.Wrapf(err, "failed to alter user %s", username) + } + + return nil +} + +// DoesUserExist checks if db contains user +func DoesUserExist(ctx context.Context, db *sql.DB, username string) (bool, error) { + row := db.QueryRowContext(ctx, "SELECT User FROM mysql.user WHERE User = ?", username) + var name string + err := row.Scan(&name) + if err != nil { + return false, nil + } + + return true, nil +} + +// DropUser drops a user from db +func DropUser(ctx context.Context, db *sql.DB, username string) error { + _, err := db.ExecContext(ctx, "DROP USER IF EXISTS ?", username) + return err +} diff --git a/v2/internal/util/mysql/privilege.go b/v2/internal/util/mysql/privilege.go new file mode 100644 index 00000000000..dc12978bc9d --- /dev/null +++ b/v2/internal/util/mysql/privilege.go @@ -0,0 +1,262 @@ +/* +Copyright (c) Microsoft Corporation. +Licensed under the MIT license. +*/ + +package mysql + +import ( + "context" + "database/sql" + "fmt" + "strings" + + "github.com/pkg/errors" + kerrors "k8s.io/apimachinery/pkg/util/errors" + + "github.com/Azure/azure-service-operator/v2/internal/set" +) + +type SQLPrivilegeDelta struct { + AddedPrivileges set.Set[string] + DeletedPrivileges set.Set[string] +} + +// TODO: ALL is actually not supported in FlexibleServer so there's not a lot of need for this. It doesn't hurt though and makes +// TODO: us more robust if we want to expand to support MySQL users on other server types (standalone server, etc). +const sqlAll = "ALL" + +func DiffCurrentAndExpectedSQLPrivileges(currentPrivileges set.Set[string], expectedPrivileges set.Set[string]) SQLPrivilegeDelta { + result := SQLPrivilegeDelta{ + AddedPrivileges: set.Make[string](), + DeletedPrivileges: set.Make[string](), + } + + for priv := range expectedPrivileges { + // Escape hatch - if they ask for ALL then we just grant ALL + // and don't delete any, since the user should have all of + // them. + if IsSQLAll(priv) { + return SQLPrivilegeDelta{ + AddedPrivileges: set.Make[string](sqlAll), + DeletedPrivileges: set.Make[string](), + } + } + + // If an expected privilege isn't in the current privilege set, we need to add it + if !currentPrivileges.Contains(priv) { + result.AddedPrivileges.Add(priv) + } + } + + for priv := range currentPrivileges { + // If a current privilege isn't in the expected set, we need to remove it + if !expectedPrivileges.Contains(priv) { + result.DeletedPrivileges.Add(priv) + } + } + + return result +} + +// IsSQLAll returns whether the string matches the special privilege value ALL. +func IsSQLAll(privilege string) bool { + return strings.EqualFold(privilege, sqlAll) +} + +// GetUserDatabasePrivileges gets the per-database privileges that the +// user has. The user can have different permissions to each +// database. The details of access are returned in the map, keyed by +// database name. +func GetUserDatabasePrivileges(ctx context.Context, db *sql.DB, user string, hostname string) (map[string]set.Set[string], error) { + hostname = HostnameOrDefault(hostname) + + // Note: This works because we only assign permissions at the DB level, not at the table, column, etc levels -- if we assigned + // permissions at more levels we would need to do something else here such as join multiple tables or + // parse SHOW GRANTS with a regex. + rows, err := db.QueryContext( + ctx, + "SELECT TABLE_SCHEMA, PRIVILEGE_TYPE FROM INFORMATION_SCHEMA.SCHEMA_PRIVILEGES WHERE GRANTEE = ?", + formatUser(user, hostname)) + if err != nil { + return nil, errors.Wrapf(err, "listing database grants for user %s", user) + } + defer rows.Close() + + results := make(map[string]set.Set[string]) + for rows.Next() { + var database, privilege string + err := rows.Scan(&database, &privilege) + if err != nil { + return nil, errors.Wrapf(err, "extracting privilege row") + } + + var privileges set.Set[string] + if existingPrivileges, found := results[database]; found { + privileges = existingPrivileges + } else { + privileges = make(set.Set[string]) + results[database] = privileges + } + privileges.Add(privilege) + } + + if rows.Err() != nil { + return nil, errors.Wrapf(rows.Err(), "iterating database privileges") + } + + return results, nil +} + +// GetUserServerPrivileges gets the server-level privileges the user has as a set. +func GetUserServerPrivileges(ctx context.Context, db *sql.DB, user string, hostname string) (set.Set[string], error) { + hostname = HostnameOrDefault(hostname) + + // Note: This works because we only assign permissions at the DB level, not at the table, column, etc levels -- if we assigned + // permissions at more levels we would need to do something else here such as join multiple tables or + // parse SHOW GRANTS with a regex. + // Remove "USAGE" as it's special and we never grant or remove it. + rows, err := db.QueryContext( + ctx, + "SELECT PRIVILEGE_TYPE FROM INFORMATION_SCHEMA.USER_PRIVILEGES WHERE GRANTEE = ? AND PRIVILEGE_TYPE != 'USAGE'", + formatUser(user, hostname)) + if err != nil { + return nil, errors.Wrapf(err, "listing grants for user %s", user) + } + defer rows.Close() + + result := make(set.Set[string]) + for rows.Next() { + var row string + err := rows.Scan(&row) + if err != nil { + return nil, errors.Wrapf(err, "extracting privilege field") + } + + result.Add(row) + } + if rows.Err() != nil { + return nil, errors.Wrapf(rows.Err(), "iterating privileges") + } + + return result, nil +} + +// ReconcileUserServerPrivileges revokes and grants server-level privileges as +// needed so the privileges for the user match those passed in. +func ReconcileUserServerPrivileges(ctx context.Context, db *sql.DB, user string, hostname string, privileges []string) error { + var errs []error + desiredPrivileges := set.Make[string](privileges...) + + currentPrivileges, err := GetUserServerPrivileges(ctx, db, user, hostname) + if err != nil { + return errors.Wrapf(err, "couldn't get existing privileges for user %s", user) + } + + privsDiff := DiffCurrentAndExpectedSQLPrivileges(currentPrivileges, desiredPrivileges) + err = addPrivileges(ctx, db, "", user, privsDiff.AddedPrivileges) + if err != nil { + errs = append(errs, err) + } + err = deletePrivileges(ctx, db, "", user, privsDiff.DeletedPrivileges) + if err != nil { + errs = append(errs, err) + } + + err = kerrors.NewAggregate(errs) + if err != nil { + return err + } + return nil +} + +// ReconcileUserDatabasePrivileges revokes and grants database privileges as needed +// so they match the ones passed in. If there's an error applying +// privileges for one database it will still continue to apply +// privileges for subsequent databases (before reporting all errors). +func ReconcileUserDatabasePrivileges(ctx context.Context, conn *sql.DB, user string, hostname string, dbPrivs map[string][]string) error { + desiredPrivs := make(map[string]set.Set[string]) + for database, privs := range dbPrivs { + desiredPrivs[database] = set.Make[string](privs...) + } + + currentPrivs, err := GetUserDatabasePrivileges(ctx, conn, user, hostname) + if err != nil { + return errors.Wrapf(err, "couldn't get existing database privileges for user %s", user) + } + + allDatabases := make(set.Set[string]) + for db := range desiredPrivs { + allDatabases.Add(db) + } + for db := range currentPrivs { + allDatabases.Add(db) + } + + var dbErrors []error + for db := range allDatabases { + privsDiff := DiffCurrentAndExpectedSQLPrivileges( + currentPrivs[db], + desiredPrivs[db], + ) + + err = addPrivileges(ctx, conn, db, user, privsDiff.AddedPrivileges) + if err != nil { + dbErrors = append(dbErrors, errors.Wrap(err, db)) + } + err = deletePrivileges(ctx, conn, db, user, privsDiff.DeletedPrivileges) + if err != nil { + dbErrors = append(dbErrors, errors.Wrap(err, db)) + } + } + + return kerrors.NewAggregate(dbErrors) +} + +func addPrivileges(ctx context.Context, db *sql.DB, database string, user string, privileges set.Set[string]) error { + if len(privileges) == 0 { + // Nothing to do + return nil + } + + toAdd := strings.Join(privileges.Values(), ",") + // TODO: Is there a way to just disable G201, which this violates? + // We say //nolint:gosec below because gosec is trying to tell us this is a dangerous SQL query with a risk of SQL + // injection. The user effectively has admin access to the DB through the operator already the minute that they can + // create users with arbitrary permission levels. + _, err := db.ExecContext(ctx, fmt.Sprintf("GRANT %s ON %s TO ?", toAdd, asGrantTarget(database)), user) //nolint:gosec + + return err +} + +func deletePrivileges(ctx context.Context, db *sql.DB, database string, user string, privileges set.Set[string]) error { + if len(privileges) == 0 { + // Nothing to do + return nil + } + + toDelete := strings.Join(privileges.Values(), ",") + // TODO: Is there a way to just disable G201, which this violates? + // We say //nolint:gosec below because gosec is trying to tell us this is a dangerous SQL query with a risk of SQL + // injection. The user effectively has admin access to the DB through the operator already the minute that they can + // create users with arbitrary permission levels. + tsql := fmt.Sprintf("REVOKE %s ON %s FROM ?", toDelete, asGrantTarget(database)) //nolint:gosec + _, err := db.ExecContext(ctx, tsql, user) + + return err +} + +// asGrantTarget formats the database name as a target suitable for a +// grant or revoke statement. If database is empty it returns "*.*" +// for server-level privileges. +func asGrantTarget(database string) string { + if database == "" { + return "*.*" + } + return fmt.Sprintf("`%s`.*", database) +} + +func formatUser(user string, hostname string) string { + // Wrap the user name in the weird formatting MySQL uses. + return fmt.Sprintf("'%s'@'%s'", user, hostname) +} diff --git a/v2/internal/util/mysql/privilege_test.go b/v2/internal/util/mysql/privilege_test.go new file mode 100644 index 00000000000..d1b38e1e9f2 --- /dev/null +++ b/v2/internal/util/mysql/privilege_test.go @@ -0,0 +1,84 @@ +/* +Copyright (c) Microsoft Corporation. +Licensed under the MIT license. +*/ + +package mysql + +import ( + "testing" + + . "github.com/onsi/gomega" + + "github.com/Azure/azure-service-operator/v2/internal/set" +) + +func TestDiffCurrentAndExpectedSQLRoles(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + currentPrivileges set.Set[string] + expectedPrivileges set.Set[string] + expectedPrivilegeAdds set.Set[string] + expectedPrivilegeDeletes set.Set[string] + }{{ + name: "Current and expected equal", + currentPrivileges: set.Set[string]{"USAGE": {}}, + expectedPrivileges: set.Set[string]{"USAGE": {}}, + expectedPrivilegeAdds: set.Make[string](), + expectedPrivilegeDeletes: set.Make[string](), + }, { + name: "Expected has single role more than current", + currentPrivileges: set.Set[string]{"USAGE": {}}, + expectedPrivileges: set.Set[string]{"USAGE": {}, "SELECT": {}}, + expectedPrivilegeAdds: set.Set[string]{"SELECT": {}}, + expectedPrivilegeDeletes: set.Make[string](), + }, { + name: "Expected has single role less than current", + currentPrivileges: set.Set[string]{"USAGE": {}, "SELECT": {}}, + expectedPrivileges: set.Set[string]{"USAGE": {}}, + expectedPrivilegeAdds: set.Make[string](), + expectedPrivilegeDeletes: set.Set[string]{"SELECT": {}}, + }, { + name: "Expected has many roles less than current", + currentPrivileges: set.Set[string]{"SELECT": {}, "INSERT": {}, "UPDATE": {}, "DELETE": {}, "CREATE": {}, "DROP": {}, "RELOAD": {}}, + expectedPrivileges: set.Set[string]{"SELECT": {}, "INSERT": {}}, + expectedPrivilegeAdds: set.Make[string](), + expectedPrivilegeDeletes: set.Set[string]{"UPDATE": {}, "DELETE": {}, "CREATE": {}, "DROP": {}, "RELOAD": {}}, + }, { + name: "Expected has many roles more than current", + currentPrivileges: set.Set[string]{"SELECT": {}, "INSERT": {}}, + expectedPrivileges: set.Set[string]{"SELECT": {}, "INSERT": {}, "UPDATE": {}, "DELETE": {}, "CREATE": {}, "DROP": {}, "RELOAD": {}}, + expectedPrivilegeAdds: set.Set[string]{"UPDATE": {}, "DELETE": {}, "CREATE": {}, "DROP": {}, "RELOAD": {}}, + expectedPrivilegeDeletes: set.Make[string](), + }, { + name: "Including ALL sets added to ALL and doesn't delete any", + currentPrivileges: set.Set[string]{"SELECT": {}, "INSERT": {}}, + expectedPrivileges: set.Set[string]{"ALL": {}, "UPDATE": {}}, + expectedPrivilegeAdds: set.Set[string]{"ALL": {}}, + expectedPrivilegeDeletes: set.Make[string](), + }, { + name: "ALL is case insensitive", + currentPrivileges: set.Set[string]{"SELECT": {}, "INSERT": {}}, + expectedPrivileges: set.Set[string]{"aLl": {}, "UPDATE": {}}, + expectedPrivilegeAdds: set.Set[string]{"ALL": {}}, + expectedPrivilegeDeletes: set.Make[string](), + }} + + // There's no test for handling ALL in current roles because we + // don't see that in the database - it gets expanded into all + // permissions. + + for _, c := range cases { + c := c + t.Run(c.name, func(t *testing.T) { + t.Parallel() + g := NewGomegaWithT(t) + + result := DiffCurrentAndExpectedSQLPrivileges(c.currentPrivileges, c.expectedPrivileges) + g.Expect(result.AddedPrivileges).To(Equal(c.expectedPrivilegeAdds)) + g.Expect(result.DeletedPrivileges).To(Equal(c.expectedPrivilegeDeletes)) + }) + } +} diff --git a/v2/test/mysql_secret_update_test.go b/v2/test/mysql_secret_update_test.go deleted file mode 100644 index 5020d6dccf6..00000000000 --- a/v2/test/mysql_secret_update_test.go +++ /dev/null @@ -1,157 +0,0 @@ -/* -Copyright (c) Microsoft Corporation. -Licensed under the MIT license. -*/ - -package test - -import ( - "context" - "database/sql" - "fmt" - "testing" - "time" - - "github.com/Azure/go-autorest/autorest/to" - _ "github.com/go-sql-driver/mysql" //sql drive link - . "github.com/onsi/gomega" - "github.com/pkg/errors" - v1 "k8s.io/api/core/v1" - - mysql "github.com/Azure/azure-service-operator/v2/api/dbformysql/v1beta20210501" - "github.com/Azure/azure-service-operator/v2/internal/testcommon" - "github.com/Azure/azure-service-operator/v2/pkg/genruntime" -) - -// Test_MySQL_Secret_Updated ensures that when a secret is modified, the modified value -// is sent to Azure. This cannot be tested in the recording tests because they do not use -// a cached client. The index functionality used to check if a secret is being used by an -// ASO resource requires the cached client (the indexes are local to the cache). -func Test_MySQL_Secret_Updated(t *testing.T) { - t.Parallel() - tc := globalTestContext.ForTest(t) - - // Force this test to run in a region that is not capacity constrained. - // location := tc.AzureRegion TODO: Uncomment this line when West US 2 is no longer constrained - location := to.StringPtr("West US") - - rg := tc.CreateTestResourceGroupAndWait() - - adminUsername := "myadmin" - adminPasswordKey := "adminPassword" - adminPassword := tc.Namer.GeneratePassword() - secret := &v1.Secret{ - ObjectMeta: tc.MakeObjectMeta("mysqlsecret"), - StringData: map[string]string{ - adminPasswordKey: adminPassword, - }, - } - - tc.CreateResource(secret) - - version := mysql.ServerPropertiesVersion8021 - secretRef := genruntime.SecretReference{ - Name: secret.Name, - Key: adminPasswordKey, - } - tier := mysql.SkuTierGeneralPurpose - flexibleServer := &mysql.FlexibleServer{ - ObjectMeta: tc.MakeObjectMeta("mysql"), - Spec: mysql.FlexibleServers_Spec{ - Location: location, - Owner: testcommon.AsOwner(rg), - Version: &version, - Sku: &mysql.Sku{ - Name: to.StringPtr("Standard_D4ds_v4"), - Tier: &tier, - }, - AdministratorLogin: to.StringPtr(adminUsername), - AdministratorLoginPassword: &secretRef, - Storage: &mysql.Storage{ - StorageSizeGB: to.IntPtr(128), - }, - }, - } - - tc.CreateResourceAndWait(flexibleServer) - - // This rule opens access to the public internet. Safe in this case - // because there's no data in the database anyway - firewallRule := &mysql.FlexibleServersFirewallRule{ - ObjectMeta: tc.MakeObjectMeta("firewall"), - Spec: mysql.FlexibleServersFirewallRules_Spec{ - Owner: testcommon.AsOwner(flexibleServer), - StartIpAddress: to.StringPtr("0.0.0.0"), - EndIpAddress: to.StringPtr("255.255.255.255"), - }, - } - - tc.CreateResourceAndWait(firewallRule) - - tc.Expect(flexibleServer.Status.FullyQualifiedDomainName).ToNot(BeNil()) - fqdn := *flexibleServer.Status.FullyQualifiedDomainName - - // Connect to the DB - conn, err := ConnectToMySQLDB( - context.Background(), - fqdn, - MySQLSystemDatabase, - MySQLServerPort, - adminUsername, - adminPassword) - tc.Expect(err).ToNot(HaveOccurred()) - // Close the connection - tc.Expect(conn.Close()).To(Succeed()) - - // Update the secret - newAdminPassword := tc.Namer.GeneratePasswordOfLength(40) - - newSecret := &v1.Secret{ - ObjectMeta: secret.ObjectMeta, - StringData: map[string]string{ - adminPasswordKey: newAdminPassword, - }, - } - tc.UpdateResource(newSecret) - - // Connect to the DB - this may fail initially as reconcile runs and MySQL - // performs the update - tc.G.Eventually( - func() error { - conn, err = ConnectToMySQLDB( - context.Background(), - fqdn, - MySQLSystemDatabase, - MySQLServerPort, - adminUsername, - newAdminPassword) - if err != nil { - return err - } - - return conn.Close() - }, - 2*time.Minute, // We expect this to pass pretty quickly - ).Should(Succeed()) -} - -const MySQLServerPort = 3306 -const MySQLDriver = "mysql" -const MySQLSystemDatabase = "mysql" - -// TODO: This will probably one day need to be put into a non-test package, but for now not bothering as we only use it to test -func ConnectToMySQLDB(ctx context.Context, fullServer string, database string, port int, user string, password string) (*sql.DB, error) { - connString := fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?tls=true&interpolateParams=true", user, password, fullServer, port, database) - - db, err := sql.Open(MySQLDriver, connString) - if err != nil { - return db, err - } - - err = db.PingContext(ctx) - if err != nil { - return db, errors.Wrapf(err, "error pinging the mysql db (%s:%d/%s)", fullServer, port, database) - } - - return db, err -} diff --git a/v2/test/mysql_test.go b/v2/test/mysql_test.go new file mode 100644 index 00000000000..67745010431 --- /dev/null +++ b/v2/test/mysql_test.go @@ -0,0 +1,462 @@ +/* +Copyright (c) Microsoft Corporation. +Licensed under the MIT license. +*/ + +package test + +import ( + "testing" + "time" + + "github.com/Azure/go-autorest/autorest/to" + _ "github.com/go-sql-driver/mysql" //sql drive link + . "github.com/onsi/gomega" + v1 "k8s.io/api/core/v1" + + mysqlbeta1 "github.com/Azure/azure-service-operator/v2/api/dbformysql/v1beta1" + mysql "github.com/Azure/azure-service-operator/v2/api/dbformysql/v1beta20210501" + resources "github.com/Azure/azure-service-operator/v2/api/resources/v1beta20200601" + "github.com/Azure/azure-service-operator/v2/internal/set" + "github.com/Azure/azure-service-operator/v2/internal/testcommon" + mysqlutil "github.com/Azure/azure-service-operator/v2/internal/util/mysql" + "github.com/Azure/azure-service-operator/v2/pkg/genruntime" +) + +func Test_MySQL_Combined(t *testing.T) { + t.Parallel() + tc := globalTestContext.ForTest(t) + + rg := tc.CreateTestResourceGroupAndWait() + + adminUsername := "myadmin" + adminPasswordKey := "adminPassword" + adminPassword := tc.Namer.GeneratePassword() + secret := newSecret(tc, adminPasswordKey, adminPassword) + + tc.CreateResource(secret) + + flexibleServer := newMySQLServer(tc, rg, adminUsername, adminPasswordKey, secret.Name) + tc.CreateResourceAndWait(flexibleServer) + + firewallRule := newMySQLServerOpenFirewallRule(tc, flexibleServer) + tc.CreateResourceAndWait(firewallRule) + + tc.Expect(flexibleServer.Status.FullyQualifiedDomainName).ToNot(BeNil()) + fqdn := *flexibleServer.Status.FullyQualifiedDomainName + + // These must run sequentially as they're mutating SQL state + tc.RunSubtests( + testcommon.Subtest{ + Name: "MySQL User Helpers", + Test: func(testContext *testcommon.KubePerTestContext) { + MySQL_User_Helpers(testContext, fqdn, adminUsername, adminPassword) + }, + }, + testcommon.Subtest{ + Name: "MySQL User CRUD", + Test: func(testContext *testcommon.KubePerTestContext) { + MySQL_User_CRUD(testContext, flexibleServer, adminPassword) + }, + }, + testcommon.Subtest{ + Name: "MySQL Secret Rollover", + Test: func(testContext *testcommon.KubePerTestContext) { + MySQL_AdminSecret_Rollvoer(testContext, fqdn, adminUsername, adminPasswordKey, adminPassword, secret) + }, + }, + ) +} + +// MySQL_AdminSecret_Rollvoer ensures that when a secret is modified, the modified value +// is sent to Azure. This cannot be tested in the recording tests because they do not use +// a cached client. The index functionality used to check if a secret is being used by an +// ASO resource requires the cached client (the indexes are local to the cache). +func MySQL_AdminSecret_Rollvoer(tc *testcommon.KubePerTestContext, fqdn string, adminUsername string, adminPasswordKey string, adminPassword string, secret *v1.Secret) { + // Connect to the DB + conn, err := mysqlutil.ConnectToDB( + tc.Ctx, + fqdn, + mysqlutil.SystemDatabase, + mysqlutil.ServerPort, + adminUsername, + adminPassword) + tc.Expect(err).ToNot(HaveOccurred()) + // Close the connection + tc.Expect(conn.Close()).To(Succeed()) + + // Update the secret + newAdminPassword := tc.Namer.GeneratePasswordOfLength(40) + + newSecret := &v1.Secret{ + ObjectMeta: secret.ObjectMeta, + StringData: map[string]string{ + adminPasswordKey: newAdminPassword, + }, + } + tc.UpdateResource(newSecret) + + // Connect to the DB - this may fail initially as reconcile runs and MySQL + // performs the update + tc.G.Eventually( + func() error { + conn, err = mysqlutil.ConnectToDB( + tc.Ctx, + fqdn, + mysqlutil.SystemDatabase, + mysqlutil.ServerPort, + adminUsername, + newAdminPassword) + if err != nil { + return err + } + + return conn.Close() + }, + 2*time.Minute, // We expect this to pass pretty quickly + ).Should(Succeed()) +} + +// We could also test this with https://hub.docker.com/_/mysql, but since we're provisioning a real SQL server anyway we might +// as well use it +func MySQL_User_Helpers(tc *testcommon.KubePerTestContext, fqdn string, adminUsername string, adminPassword string) { + // Connect to the DB + ctx := tc.Ctx + db, err := mysqlutil.ConnectToDB( + ctx, + fqdn, + mysqlutil.SystemDatabase, + mysqlutil.ServerPort, + adminUsername, + adminPassword) + tc.Expect(err).ToNot(HaveOccurred()) + defer db.Close() + + username := "testuser" + hostname := "" + userPassword := tc.Namer.GeneratePassword() + tc.Expect(mysqlutil.CreateOrUpdateUser(ctx, db, username, hostname, userPassword)).To(Succeed()) + + exists, err := mysqlutil.DoesUserExist(ctx, db, username) + tc.Expect(err).ToNot(HaveOccurred()) + tc.Expect(exists).To(BeTrue()) + + serverPrivs, err := mysqlutil.GetUserServerPrivileges(ctx, db, username, hostname) + tc.Expect(err).ToNot(HaveOccurred()) + tc.Expect(serverPrivs).To(BeEmpty()) + + dbPrivs, err := mysqlutil.GetUserDatabasePrivileges(ctx, db, username, hostname) + tc.Expect(err).ToNot(HaveOccurred()) + tc.Expect(dbPrivs).To(BeEmpty()) + + // Test setting some privs + expectedServerPrivs := []string{"CREATE USER", "PROCESS"} + tc.Expect(mysqlutil.ReconcileUserServerPrivileges(ctx, db, username, hostname, expectedServerPrivs)).To(Succeed()) + + serverPrivs, err = mysqlutil.GetUserServerPrivileges(ctx, db, username, hostname) + tc.Expect(err).ToNot(HaveOccurred()) + tc.Expect(serverPrivs).To(Equal(set.Make[string](expectedServerPrivs...))) + + // Update privs to add some and remove some + expectedServerPrivs = []string{"CREATE USER", "SHOW DATABASES"} + tc.Expect(mysqlutil.ReconcileUserServerPrivileges(ctx, db, username, hostname, expectedServerPrivs)).To(Succeed()) + + serverPrivs, err = mysqlutil.GetUserServerPrivileges(ctx, db, username, hostname) + tc.Expect(err).ToNot(HaveOccurred()) + tc.Expect(serverPrivs).To(Equal(set.Make[string](expectedServerPrivs...))) + + // Delete the user + tc.Expect(mysqlutil.DropUser(ctx, db, username)).To(Succeed()) + + exists, err = mysqlutil.DoesUserExist(ctx, db, username) + tc.Expect(err).ToNot(HaveOccurred()) + tc.Expect(exists).To(BeFalse()) +} + +func MySQL_User_CRUD(tc *testcommon.KubePerTestContext, server *mysql.FlexibleServer, adminPassword string) { + passwordKey := "password" + password := tc.Namer.GeneratePassword() + userSecret := newSecret(tc, passwordKey, password) + + tc.CreateResource(userSecret) + + username := tc.NoSpaceNamer.GenerateName("user") + user := &mysqlbeta1.User{ + ObjectMeta: tc.MakeObjectMetaWithName(username), + Spec: mysqlbeta1.UserSpec{ + Owner: testcommon.AsOwner(server), + Privileges: []string{ + "CREATE USER", + "PROCESS", + }, + LocalUser: &mysqlbeta1.LocalUserSpec{ + ServerAdminUsername: to.String(server.Spec.AdministratorLogin), + ServerAdminPassword: server.Spec.AdministratorLoginPassword, + Password: &genruntime.SecretReference{ + Name: userSecret.Name, + Key: passwordKey, + }, + }, + }, + } + tc.CreateResourcesAndWait(user) + + // Connect to the DB + ctx := tc.Ctx + fqdn := to.String(server.Status.FullyQualifiedDomainName) + conn, err := mysqlutil.ConnectToDB( + ctx, + fqdn, + mysqlutil.SystemDatabase, + mysqlutil.ServerPort, + to.String(server.Spec.AdministratorLogin), + adminPassword) + tc.Expect(err).ToNot(HaveOccurred()) + defer conn.Close() + + // Confirm that we have the right privs on the actual server + serverPrivs, err := mysqlutil.GetUserServerPrivileges(tc.Ctx, conn, username, "") + tc.Expect(err).ToNot(HaveOccurred()) + tc.Expect(serverPrivs).To(Equal(set.Make[string](user.Spec.Privileges...))) + + // Update the user + old := user.DeepCopy() + user.Spec.Privileges = []string{ + "CREATE USER", + "PROCESS", + "SHOW DATABASES", + } + tc.PatchResourceAndWait(old, user) + + // Confirm that we have the right privs on the actual server + serverPrivs, err = mysqlutil.GetUserServerPrivileges(tc.Ctx, conn, username, "") + tc.Expect(err).ToNot(HaveOccurred()) + tc.Expect(serverPrivs).To(Equal(set.Make[string](user.Spec.Privileges...))) + + // Close the connection + tc.Expect(conn.Close()).To(Succeed()) + + // Confirm we can connect as the user + conn, err = mysqlutil.ConnectToDB( + tc.Ctx, + fqdn, + "", + mysqlutil.ServerPort, + user.Spec.AzureName, + password) + tc.Expect(err).ToNot(HaveOccurred()) + // Close the connection + tc.Expect(conn.Close()).To(Succeed()) + + // Update the secret + newPassword := tc.Namer.GeneratePassword() + newSecret := &v1.Secret{ + ObjectMeta: userSecret.ObjectMeta, + StringData: map[string]string{ + passwordKey: newPassword, + }, + } + tc.UpdateResource(newSecret) + + // Connect to the DB as the user, using the new secret + tc.G.Eventually( + func() error { + conn, err = mysqlutil.ConnectToDB( + tc.Ctx, + fqdn, + "", + mysqlutil.ServerPort, + user.Spec.AzureName, + newPassword) + if err != nil { + return err + } + + return conn.Close() + }, + 2*time.Minute, // We expect this to pass pretty quickly + ).Should(Succeed()) + + tc.DeleteResourceAndWait(user) +} + +//func Test_MySQL_Helpers(t *testing.T) { +// t.Parallel() +// tc := globalTestContext.ForTest(t) +// +// rg := tc.CreateTestResourceGroupAndWait() +// +// adminUsername := "myadmin" +// adminPasswordKey := "adminPassword" +// adminPassword := tc.Namer.GeneratePassword() +// secret := newSecret(tc, adminPasswordKey, adminPassword) +// +// tc.CreateResource(secret) +// +// flexibleServer := newMySQLServer(tc, rg, adminUsername, adminPasswordKey, secret.Name) +// tc.CreateResourceAndWait(flexibleServer) +// +// firewallRule := newMySQLServerOpenFirewallRule(tc, flexibleServer) +// tc.CreateResourceAndWait(firewallRule) +// +// tc.Expect(flexibleServer.Status.FullyQualifiedDomainName).ToNot(BeNil()) +// fqdn := *flexibleServer.Status.FullyQualifiedDomainName +// +// // Connect to the DB +// ctx := context.Background() +// db, err := mysqlutil.ConnectToDB( +// ctx, +// fqdn, +// mysqlutil.SystemDatabase, +// mysqlutil.ServerPort, +// adminUsername, +// adminPassword) +// tc.Expect(err).ToNot(HaveOccurred()) +// defer db.Close() +// +// // TODO: These should be subtests, maybe? +// username := "testuser" +// hostname := "" +// userPassword := tc.Namer.GeneratePassword() +// tc.Expect(mysqlutil.CreateUser(ctx, db, username, hostname, userPassword)).To(Succeed()) +// +// exists, err := mysqlutil.DoesUserExist(ctx, db, username) +// tc.Expect(err).ToNot(HaveOccurred()) +// tc.Expect(exists).To(BeTrue()) +// +// serverPrivs, err := mysqlutil.ExtractUserServerPrivileges(ctx, db, username, hostname) +// tc.Expect(err).ToNot(HaveOccurred()) +// tc.Expect(serverPrivs).To(BeEmpty()) +// +// dbPrivs, err := mysqlutil.ExtractUserDatabasePrivileges(ctx, db, username, hostname) +// tc.Expect(err).ToNot(HaveOccurred()) +// tc.Expect(dbPrivs).To(BeEmpty()) +// +// // Test setting some privs +// expectedServerPrivs := []string{"CREATE USER", "PROCESS"} +// tc.Expect(mysqlutil.EnsureUserServerPrivileges(ctx, db, username, hostname, expectedServerPrivs)).To(Succeed()) +// +// serverPrivs, err = mysqlutil.ExtractUserServerPrivileges(ctx, db, username, hostname) +// tc.Expect(err).ToNot(HaveOccurred()) +// tc.Expect(serverPrivs).To(Equal(set.Make[string](expectedServerPrivs...))) +// +// // Update privs to add some and remove some +// expectedServerPrivs = []string{"CREATE USER", "SHOW DATABASES"} +// tc.Expect(mysqlutil.EnsureUserServerPrivileges(ctx, db, username, hostname, expectedServerPrivs)).To(Succeed()) +// +// serverPrivs, err = mysqlutil.ExtractUserServerPrivileges(ctx, db, username, hostname) +// tc.Expect(err).ToNot(HaveOccurred()) +// tc.Expect(serverPrivs).To(Equal(set.Make[string](expectedServerPrivs...))) +// +// // Delete the user +// tc.Expect(mysqlutil.DropUser(ctx, db, username)).To(Succeed()) +// +// exists, err = mysqlutil.DoesUserExist(ctx, db, username) +// tc.Expect(err).ToNot(HaveOccurred()) +// tc.Expect(exists).To(BeFalse()) +//} + +func Test_MySQL_User(t *testing.T) { + t.Parallel() + tc := globalTestContext.ForTest(t) + + rg := tc.CreateTestResourceGroupAndWait() + + adminUsername := "myadmin" + adminPasswordKey := "adminPassword" + adminPassword := tc.Namer.GeneratePassword() + adminSecret := newSecret(tc, adminPasswordKey, adminPassword) + + passwordKey := "password" + password := tc.Namer.GeneratePassword() + userSecret := newSecret(tc, passwordKey, password) + + tc.CreateResource(adminSecret) + tc.CreateResource(userSecret) + + flexibleServer := newMySQLServer(tc, rg, adminUsername, adminPasswordKey, adminSecret.Name) + firewallRule := newMySQLServerOpenFirewallRule(tc, flexibleServer) + + user := &mysqlbeta1.User{ + ObjectMeta: tc.MakeObjectMetaWithName(tc.NoSpaceNamer.GenerateName("user")), + Spec: mysqlbeta1.UserSpec{ + Owner: testcommon.AsOwner(flexibleServer), + Privileges: []string{ + "CREATE USER", + "PROCESS", + }, + LocalUser: &mysqlbeta1.LocalUserSpec{ + ServerAdminUsername: adminUsername, + ServerAdminPassword: flexibleServer.Spec.AdministratorLoginPassword, + Password: &genruntime.SecretReference{ + Name: userSecret.Name, + Key: passwordKey, + }, + }, + }, + } + tc.CreateResourcesAndWait(flexibleServer, firewallRule, user) + + // TODO: Test other stuff? + // TODO: Password rollover? + + tc.DeleteResourceAndWait(user) +} + +func newSecret(tc *testcommon.KubePerTestContext, key string, password string) *v1.Secret { + secret := &v1.Secret{ + ObjectMeta: tc.MakeObjectMeta("mysqlsecret"), + StringData: map[string]string{ + key: password, + }, + } + + return secret +} + +func newMySQLServer(tc *testcommon.KubePerTestContext, rg *resources.ResourceGroup, adminUsername string, adminKey string, adminSecretName string) *mysql.FlexibleServer { + // Force this test to run in a region that is not capacity constrained. + // location := tc.AzureRegion TODO: Uncomment this line when West US 2 is no longer constrained + location := to.StringPtr("West US") + + version := mysql.ServerPropertiesVersion8021 + secretRef := genruntime.SecretReference{ + Name: adminSecretName, + Key: adminKey, + } + tier := mysql.SkuTierGeneralPurpose + flexibleServer := &mysql.FlexibleServer{ + ObjectMeta: tc.MakeObjectMeta("mysql"), + Spec: mysql.FlexibleServers_Spec{ + Location: location, + Owner: testcommon.AsOwner(rg), + Version: &version, + Sku: &mysql.Sku{ + Name: to.StringPtr("Standard_D4ds_v4"), + Tier: &tier, + }, + AdministratorLogin: to.StringPtr(adminUsername), + AdministratorLoginPassword: &secretRef, + Storage: &mysql.Storage{ + StorageSizeGB: to.IntPtr(128), + }, + }, + } + + return flexibleServer +} + +func newMySQLServerOpenFirewallRule(tc *testcommon.KubePerTestContext, flexibleServer *mysql.FlexibleServer) *mysql.FlexibleServersFirewallRule { + // This rule opens access to the public internet. Safe in this case + // because there's no data in the database anyway + firewallRule := &mysql.FlexibleServersFirewallRule{ + ObjectMeta: tc.MakeObjectMeta("firewall"), + Spec: mysql.FlexibleServersFirewallRules_Spec{ + Owner: testcommon.AsOwner(flexibleServer), + StartIpAddress: to.StringPtr("0.0.0.0"), + EndIpAddress: to.StringPtr("255.255.255.255"), + }, + } + + return firewallRule +}