From 81091cf18af321cc239af0680780b296d5856d51 Mon Sep 17 00:00:00 2001 From: Matthew Christopher Date: Sun, 11 Jul 2021 11:29:03 -0700 Subject: [PATCH] Support user specified MySQLServer secrets (#1625) * Support user specified MySQLServer secrets - The specified secret must be a Kubernetes secret. - The specified secret must contain a "username" and "password" field. - The specified secret must be in the same namespace as the MySQLServer. - If the specified secret doesn't exist, reconciliation will be blocked until the secret does exist. Once the secret is created, reconciliation will continue as normal. - The operator does not make the user specified secret owned by the MySQLServer. - The operator still creates a secret containing connection string details and username/password for the server. This secret is named as it was before. This means that the customer specified username and password are consumed to create this secret, but other resources such as MySQLUser still consume the generated secret file. --- api/v1alpha2/mysqlserver_types.go | 9 + controllers/mysql_combined_test.go | 77 +++++++- controllers/mysqlserver_controller_test.go | 90 +++++++++- controllers/suite_test.go | 1 + docs/services/mysql/mysql.md | 37 +++- main.go | 1 + .../mysql/mysqlaaduser/reconcile.go | 8 +- .../mysql/mysqluser/mysqluser.go | 6 +- .../mysql/mysqluser/mysqluser_reconcile.go | 7 +- pkg/resourcemanager/mysql/server/client.go | 54 +++--- pkg/resourcemanager/mysql/server/reconcile.go | 165 +++++++++++++----- 11 files changed, 372 insertions(+), 83 deletions(-) diff --git a/api/v1alpha2/mysqlserver_types.go b/api/v1alpha2/mysqlserver_types.go index 681d9cfe838..9eb4b4519d2 100644 --- a/api/v1alpha2/mysqlserver_types.go +++ b/api/v1alpha2/mysqlserver_types.go @@ -25,6 +25,15 @@ type MySQLServerSpec struct { ReplicaProperties ReplicaProperties `json:"replicaProperties,omitempty"` StorageProfile *MySQLStorageProfile `json:"storageProfile,omitempty"` KeyVaultToStoreSecrets string `json:"keyVaultToStoreSecrets,omitempty"` + + // +kubebuilder:validation:MinLength=1 + // AdminSecret is the name of a Kubernetes secret containing the username and password of the + // MySQLServer administrator account. When specified, the username and password fields of this + // secret will be included in the generated secret associated with this MySQLServer. + // If AdminSecret is specified but a secret with the given name is not found in the same namespace + // as the MySQLServer, then reconciliation will block until the secret is created. + // If this is not specified, a username and password will be automatically generated. + AdminSecret string `json:"adminSecret,omitempty"` } // +kubebuilder:object:root=true diff --git a/controllers/mysql_combined_test.go b/controllers/mysql_combined_test.go index f3ae05132aa..ab19853dff0 100644 --- a/controllers/mysql_combined_test.go +++ b/controllers/mysql_combined_test.go @@ -10,12 +10,14 @@ import ( "reflect" "testing" - "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/types" azurev1alpha1 "github.com/Azure/azure-service-operator/api/v1alpha1" "github.com/Azure/azure-service-operator/api/v1alpha2" + "github.com/Azure/azure-service-operator/pkg/helpers" "github.com/Azure/azure-service-operator/pkg/resourcemanager/mysql" "github.com/Azure/azure-service-operator/pkg/resourcemanager/mysql/mysqluser" "github.com/Azure/azure-service-operator/pkg/secrets" @@ -113,7 +115,7 @@ func TestMySQLHappyPath(t *testing.T) { } func RunMySQLUserHappyPath(ctx context.Context, t *testing.T, mySQLServerName string, mySQLDBName string, rgName string) { - assert := assert.New(t) + assert := require.New(t) // Create a user in the DB username := GenerateTestResourceNameWithRandom("user", 10) @@ -197,3 +199,74 @@ func RunMySQLUserHappyPath(ctx context.Context, t *testing.T, mySQLServerName st return true }, tc.timeout, tc.retry, "waiting for DB user to be updated") } + +func TestMySQLUserSuppliedPassword(t *testing.T) { + t.Parallel() + defer PanicRecover(t) + ctx := context.Background() + assert := require.New(t) + + // Add any setup steps that needs to be executed before each test + rgLocation := "westus2" + rgName := tc.resourceGroupName + mySQLServerName := GenerateTestResourceNameWithRandom("mysql-srv", 10) + + adminSecretName := GenerateTestResourceNameWithRandom("mysqlsecret", 10) + adminUsername := helpers.GenerateRandomUsername(10) + adminPassword := helpers.NewPassword() + // Create the secret + secret := &v1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: adminSecretName, + Namespace: "default", + }, + StringData: map[string]string{ + "username": adminUsername, + "password": adminPassword, + }, + } + err := tc.k8sClient.Create(ctx, secret) + assert.NoError(err) + + // Create the mySQLServer object and expect the Reconcile to be created + mySQLServerInstance := v1alpha2.NewDefaultMySQLServer(mySQLServerName, rgName, rgLocation) + mySQLServerInstance.Spec.AdminSecret = adminSecretName + RequireInstance(ctx, t, tc, mySQLServerInstance) + + mySQLDBName := GenerateTestResourceNameWithRandom("mysql-db", 10) + // Create the mySQLDB object and expect the Reconcile to be created + mySQLDBInstance := &azurev1alpha1.MySQLDatabase{ + ObjectMeta: metav1.ObjectMeta{ + Name: mySQLDBName, + Namespace: "default", + }, + Spec: azurev1alpha1.MySQLDatabaseSpec{ + Server: mySQLServerName, + ResourceGroup: rgName, + }, + } + EnsureInstance(ctx, t, tc, mySQLDBInstance) + + // This rule opens access to the public internet, but in this case + // there's literally no data in the database + ruleName := GenerateTestResourceNameWithRandom("mysql-fw", 10) + ruleInstance := &azurev1alpha1.MySQLFirewallRule{ + ObjectMeta: metav1.ObjectMeta{ + Name: ruleName, + Namespace: "default", + }, + Spec: azurev1alpha1.MySQLFirewallRuleSpec{ + Server: mySQLServerName, + ResourceGroup: rgName, + StartIPAddress: "0.0.0.0", + EndIPAddress: "255.255.255.255", + }, + } + EnsureInstance(ctx, t, tc, ruleInstance) + + // Create user and ensure it can be updated + RunMySQLUserHappyPath(ctx, t, mySQLServerName, mySQLDBName, rgName) + + EnsureDelete(ctx, t, tc, mySQLDBInstance) + EnsureDelete(ctx, t, tc, mySQLServerInstance) +} diff --git a/controllers/mysqlserver_controller_test.go b/controllers/mysqlserver_controller_test.go index 3bb036c7aaf..19564fc4456 100644 --- a/controllers/mysqlserver_controller_test.go +++ b/controllers/mysqlserver_controller_test.go @@ -9,11 +9,15 @@ import ( "context" "testing" + "github.com/stretchr/testify/require" + "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "github.com/Azure/azure-service-operator/api/v1alpha2" "github.com/Azure/azure-service-operator/pkg/errhelp" ) -func TestMySQLServerControllerNoResourceGroup(t *testing.T) { +func TestMySQLServerNoResourceGroup(t *testing.T) { t.Parallel() defer PanicRecover(t) ctx := context.Background() @@ -30,7 +34,7 @@ func TestMySQLServerControllerNoResourceGroup(t *testing.T) { EnsureDelete(ctx, t, tc, mySQLServerInstance) } -func TestMySQLServerControllerBadLocation(t *testing.T) { +func TestMySQLServerBadLocation(t *testing.T) { t.Parallel() defer PanicRecover(t) ctx := context.Background() @@ -46,3 +50,85 @@ func TestMySQLServerControllerBadLocation(t *testing.T) { EnsureInstanceWithResult(ctx, t, tc, mySQLServerInstance, errhelp.InvalidResourceLocation, false) EnsureDelete(ctx, t, tc, mySQLServerInstance) } + +func TestMySQLServerMissingUserSpecifiedSecret(t *testing.T) { + t.Parallel() + defer PanicRecover(t) + ctx := context.Background() + assert := require.New(t) + + // Add any setup steps that needs to be executed before each test + mySQLServerName := GenerateTestResourceNameWithRandom("mysql-srv", 10) + + mySQLServerInstance := v1alpha2.NewDefaultMySQLServer(mySQLServerName, tc.resourceGroupName, tc.resourceGroupLocation) + mySQLServerInstance.Spec.AdminSecret = "doesntexist" + + EnsureInstanceWithResult(ctx, t, tc, mySQLServerInstance, "Failed to get AdminSecret", false) + + // Confirm that the resource is not done reconciling + assert.Equal(false, mySQLServerInstance.Status.FailedProvisioning) + assert.Equal(false, mySQLServerInstance.Status.Provisioned) + // The fact that provisioning is false is an artifact of where we set provisioning in the reconcile loop + // and also how we're inconsistent with what exactly provisioning means in the context of these resources. + // Changing it is a larger change though so asserting it is false for now. + assert.Equal(false, mySQLServerInstance.Status.Provisioning) + + EnsureDelete(ctx, t, tc, mySQLServerInstance) +} + +func TestMySQLServerUserSpecifiedSecretMissingPassword(t *testing.T) { + t.Parallel() + defer PanicRecover(t) + ctx := context.Background() + assert := require.New(t) + + // Add any setup steps that needs to be executed before each test + mySQLServerName := GenerateTestResourceNameWithRandom("mysql-srv", 10) + secretName := GenerateTestResourceNameWithRandom("mysqlserversecret", 10) + secret := &v1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: secretName, + Namespace: "default", + }, + StringData: map[string]string{ + "username": "testuser", + }, + } + err := tc.k8sClient.Create(ctx, secret) + assert.NoError(err) + + mySQLServerInstance := v1alpha2.NewDefaultMySQLServer(mySQLServerName, tc.resourceGroupName, tc.resourceGroupLocation) + mySQLServerInstance.Spec.AdminSecret = secretName + + EnsureInstanceWithResult(ctx, t, tc, mySQLServerInstance, "is missing required \"password\" field", false) + EnsureDelete(ctx, t, tc, mySQLServerInstance) +} + +func TestMySQLServerUserSpecifiedSecretMissingUsername(t *testing.T) { + t.Parallel() + defer PanicRecover(t) + ctx := context.Background() + assert := require.New(t) + + // Add any setup steps that needs to be executed before each test + mySQLServerName := GenerateTestResourceNameWithRandom("mysql-srv", 10) + secretName := GenerateTestResourceNameWithRandom("mysqlserversecret", 10) + secret := &v1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: secretName, + Namespace: "default", + }, + StringData: map[string]string{ + "password": "testpassword", + }, + } + err := tc.k8sClient.Create(ctx, secret) + assert.NoError(err) + + mySQLServerInstance := v1alpha2.NewDefaultMySQLServer(mySQLServerName, tc.resourceGroupName, tc.resourceGroupLocation) + mySQLServerInstance.Spec.AdminSecret = secretName + + EnsureInstanceWithResult(ctx, t, tc, mySQLServerInstance, "is missing required \"username\" field", false) + EnsureDelete(ctx, t, tc, mySQLServerInstance) +} + diff --git a/controllers/suite_test.go b/controllers/suite_test.go index d3d4bd022aa..6dc0e27229f 100644 --- a/controllers/suite_test.go +++ b/controllers/suite_test.go @@ -722,6 +722,7 @@ func setup() error { config.GlobalCredentials(), secretClient, k8sManager.GetScheme(), + k8sManager.GetClient(), ), Telemetry: telemetry.InitializeTelemetryDefault( "MySQLServer", diff --git a/docs/services/mysql/mysql.md b/docs/services/mysql/mysql.md index 68a7ebd466e..dd804cd0708 100644 --- a/docs/services/mysql/mysql.md +++ b/docs/services/mysql/mysql.md @@ -7,6 +7,10 @@ The MySQL operator suite consists of the following operators. 1. MySQL server - Deploys an `Azure Database for MySQL server` given the Location, Resource group and other properties. This operator also helps creating read replicas for MySQL server. 2. MySQL database - Deploys a database under the given `Azure Database for MySQL server` 3. MySQL firewall rule - Deploys a firewall rule to allow access to the `Azure Database for MySQL server` from the specified IP range +4. MySQL virtual network rule - Deploys a virtual network rule place the `Azure Database for MySQL server` into an Azure Virtual Network Subnet. +4. MySQL administrator - Sets the AAD Administrator of the `Azure Database for MySQL server` to the specified AAD identity. +5. MySQL user - Deploys a user into the `Azure Database for MySQL server`. +6. MySQL AAD User - Deploys an AAD user into the `Azure Database for MySQL server`. ### MySQL server @@ -17,15 +21,17 @@ The value for kind, `MySQLServer` is the Custom Resource Definition (CRD) name. The values under `spec` provide the values for the location where you want to create the server at and the Resource group in which you want to create it under. It also contains other values that are required to create the server like the `serverVersion`, `sslEnforcement` and the `sku` information. -Along with creating the MySQL server, this operator also generates the admin username and password for the MySQL server and stores it in a kube secret or keyvault (based on what is specified) with the same name as the MySQL server. +The `adminSecret` is optional and if provided must point to a Kubernetes secret containing a `username` and `password` field. If not specified, the operator will generate an administrator account `username` and `password`. +Along with creating the MySQL server, this operator also generates the admin `username` and `password` for the MySQL server and stores it in a kube secret or keyvault (based on what is specified). The generated secret is named according to [secrets naming](/docs/secrets.md). This secret contains the following fields. - -- `fullyqualifiedservername` : Fully qualified name of the MySQL server such as mysqlserver.mysql.database.azure.com -- `mysqlservername` : MySQL server name -- `username` : Server admin -- `password` : Password for the server admin -- `fullyqualifiedusername` : Fully qualified user name that is required by some apps such as @ +| Secret field | Content | +| -------------------------- | --------------------------------------------------------------------------------------------- | +| `fullyQualifiedServerName` | Fully qualified name of the MySQL server. Example: `mysqlserver.mysql.database.azure.com`. | +| `mySqlServerName` | MySQL server name. | +| `username` | Server admin account name. | +| `password` | Server admin account password. | +| `fullyQualifiedUsername` | Fully qualified user name that is required by some apps. Example: `@`. | For more information on where and how secrets are stored, look [here](/docs/secrets.md) @@ -65,6 +71,12 @@ The `server` indicates the MySQL server on which you want to configure the new M *Note*: When using MySQL Virtual Network Rules, the `Basic` SKU is not a valid op +### MySQL administrator + +The MySQL administrator operator allows you to add an [AAD administrator](https://docs.microsoft.com/azure/mysql/concepts-azure-ad-authentication) to the MySQL server. + +Here is a [sample YAML](/config/samples/azure_v1alpha1_mysqlserveradministrator.yaml). + ### MySQL user The MySQL user operator allows you to add a new user to an existing MySQL database. @@ -79,6 +91,17 @@ The operator supports grant specified privileges using the concept of `roles`, a The username is defined by `username`. The MySQL server admin secret is stored in the secret with name `adminSecret` in the keyvault named `adminSecretKeyVault`. +### MySQL AAD user +The MySQL AAD user operator allows you to add a new AAD user to an existing MySQL database. + +Here is a [sample YAML](/config/samples/azure_v1alpha1_mysqlaaduser.yaml). + +This controller is only avilable when using [Managed Identity authentication](https://github.com/Azure/azure-service-operator/blob/master/docs/howto/managedidentity.md) with ASO. +Attempting to use it without Managed Identity will result in an authentication error. + +The AAD identity the operator is running as must have permissions to create users in the MySQLServer. This is most commonly granted by making the operator managed identity the MySQL Administrator using the +[MySQL administrator](mysql-administrator) operator described above. + ## Deploy, view and delete resources You can follow the steps [here](/docs/howto/resourceprovision.md) to deploy, view and delete resources. diff --git a/main.go b/main.go index 7208b228a08..61e16ecdbe3 100644 --- a/main.go +++ b/main.go @@ -710,6 +710,7 @@ func main() { config.GlobalCredentials(), secretClient, mgr.GetScheme(), + mgr.GetClient(), ), Telemetry: telemetry.InitializeTelemetryDefault( "MySQLServer", diff --git a/pkg/resourcemanager/mysql/mysqlaaduser/reconcile.go b/pkg/resourcemanager/mysql/mysqlaaduser/reconcile.go index d8752650b43..084d5ea2359 100644 --- a/pkg/resourcemanager/mysql/mysqlaaduser/reconcile.go +++ b/pkg/resourcemanager/mysql/mysqlaaduser/reconcile.go @@ -195,12 +195,8 @@ func (m *MySQLAADUserManager) Delete(ctx context.Context, obj runtime.Object, op // GetServer retrieves a server func (m *MySQLAADUserManager) GetServer(ctx context.Context, resourceGroupName, serverName string) (mysqlmgmt.Server, error) { - // We don't need to pass the secret client and scheme because - // they're not used getting the server. - // TODO: This feels a bit dodgy, consider taking secret client and - // scheme just so we can pass them in here. - client := mysqlserver.NewMySQLServerClient(m.Creds, nil, nil) - return client.GetServer(ctx, resourceGroupName, serverName) + client := mysqlserver.MakeMySQLServerAzureClient(m.Creds) + return client.Get(ctx, resourceGroupName, serverName) } // GetParents gets the parents of the user diff --git a/pkg/resourcemanager/mysql/mysqluser/mysqluser.go b/pkg/resourcemanager/mysql/mysqluser/mysqluser.go index 1c7e3dd30fb..f5debce6402 100644 --- a/pkg/resourcemanager/mysql/mysqluser/mysqluser.go +++ b/pkg/resourcemanager/mysql/mysqluser/mysqluser.go @@ -56,8 +56,10 @@ func (m *MySqlUserManager) GetDB(ctx context.Context, resourceGroupName string, // GetServer retrieves a server func (m *MySqlUserManager) GetServer(ctx context.Context, resourceGroupName, serverName string) (mysqlmgmt.Server, error) { - client := mysqlserver.NewMySQLServerClient(m.Creds, m.SecretClient, m.Scheme) - return client.GetServer(ctx, resourceGroupName, serverName) + // TODO: It's only ok to pass nil for KubeReader here because we know it's not needed to perform GET server. + // TODO: Ideally this would be done via a different struct than the one that also does MySQLServer reconciles + client := mysqlserver.MakeMySQLServerAzureClient(m.Creds) + return client.Get(ctx, resourceGroupName, serverName) } // CreateUser creates user with secret credentials diff --git a/pkg/resourcemanager/mysql/mysqluser/mysqluser_reconcile.go b/pkg/resourcemanager/mysql/mysqluser/mysqluser_reconcile.go index 64a611833e7..be78d964243 100644 --- a/pkg/resourcemanager/mysql/mysqluser/mysqluser_reconcile.go +++ b/pkg/resourcemanager/mysql/mysqluser/mysqluser_reconcile.go @@ -9,6 +9,7 @@ import ( "reflect" "strings" + mysqlserver "github.com/Azure/azure-service-operator/pkg/resourcemanager/mysql/server" _ "github.com/go-sql-driver/mysql" //sql drive link "github.com/pkg/errors" "k8s.io/apimachinery/pkg/runtime" @@ -75,9 +76,9 @@ func (s *MySqlUserManager) Ensure(ctx context.Context, obj runtime.Object, opts return false, err } - adminUser := string(adminSecret["fullyQualifiedUsername"]) - adminPassword := string(adminSecret[MSecretPasswordKey]) - fullServerName := string(adminSecret["fullyQualifiedServerName"]) + adminUser := string(adminSecret[mysqlserver.FullyQualifiedUsernameSecretKey]) + adminPassword := string(adminSecret[mysqlserver.PasswordSecretKey]) + fullServerName := string(adminSecret[mysqlserver.FullyQualifiedServerNameSecretKey]) db, err := mysql.ConnectToSqlDB( ctx, diff --git a/pkg/resourcemanager/mysql/server/client.go b/pkg/resourcemanager/mysql/server/client.go index 4ea6255c0fb..89093247937 100644 --- a/pkg/resourcemanager/mysql/server/client.go +++ b/pkg/resourcemanager/mysql/server/client.go @@ -6,30 +6,36 @@ package server import ( "context" - mysql "github.com/Azure/azure-sdk-for-go/services/mysql/mgmt/2017-12-01/mysql" + "github.com/Azure/azure-sdk-for-go/services/mysql/mgmt/2017-12-01/mysql" + "github.com/Azure/go-autorest/autorest/to" + "k8s.io/apimachinery/pkg/runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "github.com/Azure/azure-service-operator/api/v1alpha2" "github.com/Azure/azure-service-operator/pkg/resourcemanager/config" "github.com/Azure/azure-service-operator/pkg/resourcemanager/iam" "github.com/Azure/azure-service-operator/pkg/secrets" - "github.com/Azure/go-autorest/autorest/to" - "k8s.io/apimachinery/pkg/runtime" ) type MySQLServerClient struct { Creds config.Credentials SecretClient secrets.SecretClient Scheme *runtime.Scheme + // KubeReader is used to read secrets in the case the customer has specified a secret containing their + // MySQLServer admin username/password + KubeReader client.Reader } -func NewMySQLServerClient(creds config.Credentials, secretclient secrets.SecretClient, scheme *runtime.Scheme) *MySQLServerClient { +func NewMySQLServerClient(creds config.Credentials, secretClient secrets.SecretClient, scheme *runtime.Scheme, kubeReader client.Reader) *MySQLServerClient { return &MySQLServerClient{ Creds: creds, - SecretClient: secretclient, + SecretClient: secretClient, Scheme: scheme, + KubeReader: kubeReader, } } -func getMySQLServersClient(creds config.Credentials) mysql.ServersClient { +func MakeMySQLServerAzureClient(creds config.Credentials) mysql.ServersClient { serversClient := mysql.NewServersClientWithBaseURI(config.BaseURI(), creds.SubscriptionID()) a, _ := iam.GetResourceManagementAuthorizer(creds) serversClient.Authorizer = a @@ -37,17 +43,17 @@ func getMySQLServersClient(creds config.Credentials) mysql.ServersClient { return serversClient } -func getMySQLCheckNameAvailabilityClient(creds config.Credentials) mysql.CheckNameAvailabilityClient { - nameavailabilityClient := mysql.NewCheckNameAvailabilityClientWithBaseURI(config.BaseURI(), creds.SubscriptionID()) +func MakeMySQLCheckNameAvailabilityAzureClient(creds config.Credentials) mysql.CheckNameAvailabilityClient { + nameAvailabilityClient := mysql.NewCheckNameAvailabilityClientWithBaseURI(config.BaseURI(), creds.SubscriptionID()) a, _ := iam.GetResourceManagementAuthorizer(creds) - nameavailabilityClient.Authorizer = a - nameavailabilityClient.AddToUserAgent(config.UserAgent()) - return nameavailabilityClient + nameAvailabilityClient.Authorizer = a + nameAvailabilityClient.AddToUserAgent(config.UserAgent()) + return nameAvailabilityClient } func (m *MySQLServerClient) CheckServerNameAvailability(ctx context.Context, servername string) (bool, error) { - client := getMySQLCheckNameAvailabilityClient(m.Creds) + client := MakeMySQLCheckNameAvailabilityAzureClient(m.Creds) resourceType := "Microsoft.DBforMySQL/servers" @@ -63,9 +69,17 @@ func (m *MySQLServerClient) CheckServerNameAvailability(ctx context.Context, ser } -func (m *MySQLServerClient) CreateServerIfValid(ctx context.Context, instance v1alpha2.MySQLServer, tags map[string]*string, skuInfo mysql.Sku, adminlogin string, adminpassword string, createmode mysql.CreateMode, hash string) (pollingURL string, server mysql.Server, err error) { +func (m *MySQLServerClient) CreateServerIfValid( + ctx context.Context, + instance v1alpha2.MySQLServer, + tags map[string]*string, + skuInfo mysql.Sku, + adminUser string, + adminPassword string, + createMode mysql.CreateMode, + hash string) (pollingURL string, server mysql.Server, err error) { - client := getMySQLServersClient(m.Creds) + client := MakeMySQLServerAzureClient(m.Creds) // Check if name is valid if this is the first create call valid, err := m.CheckServerNameAvailability(ctx, instance.Name) @@ -81,7 +95,7 @@ func (m *MySQLServerClient) CreateServerIfValid(ctx context.Context, instance v1 storageProfile = &obj } - if createmode == mysql.CreateModeReplica { + if createMode == mysql.CreateModeReplica { serverProperties = &mysql.ServerPropertiesForReplica{ SourceServerID: to.StringPtr(instance.Spec.ReplicaProperties.SourceServerId), CreateMode: mysql.CreateModeReplica, @@ -90,8 +104,8 @@ func (m *MySQLServerClient) CreateServerIfValid(ctx context.Context, instance v1 } else { serverProperties = &mysql.ServerPropertiesForDefaultCreate{ - AdministratorLogin: &adminlogin, - AdministratorLoginPassword: &adminpassword, + AdministratorLogin: &adminUser, + AdministratorLoginPassword: &adminPassword, Version: mysql.ServerVersion(instance.Spec.ServerVersion), SslEnforcement: mysql.SslEnforcementEnum(instance.Spec.SSLEnforcement), CreateMode: mysql.CreateModeServerPropertiesForCreate, @@ -147,8 +161,7 @@ func (m *MySQLServerClient) CreateServerIfValid(ctx context.Context, instance v1 } func (m *MySQLServerClient) DeleteServer(ctx context.Context, resourcegroup string, servername string) (status string, err error) { - - client := getMySQLServersClient(m.Creds) + client := MakeMySQLServerAzureClient(m.Creds) _, err = client.Get(ctx, resourcegroup, servername) if err == nil { // Server present, so go ahead and delete @@ -161,7 +174,6 @@ func (m *MySQLServerClient) DeleteServer(ctx context.Context, resourcegroup stri } func (m *MySQLServerClient) GetServer(ctx context.Context, resourcegroup string, servername string) (server mysql.Server, err error) { - - client := getMySQLServersClient(m.Creds) + client := MakeMySQLServerAzureClient(m.Creds) return client.Get(ctx, resourcegroup, servername) } diff --git a/pkg/resourcemanager/mysql/server/reconcile.go b/pkg/resourcemanager/mysql/server/reconcile.go index e07926c8a98..889c6d5ee9f 100644 --- a/pkg/resourcemanager/mysql/server/reconcile.go +++ b/pkg/resourcemanager/mysql/server/reconcile.go @@ -8,10 +8,13 @@ import ( "fmt" "strings" - mysql "github.com/Azure/azure-sdk-for-go/services/mysql/mgmt/2017-12-01/mysql" + "github.com/Azure/azure-sdk-for-go/services/mysql/mgmt/2017-12-01/mysql" "github.com/Azure/go-autorest/autorest/to" + "github.com/pkg/errors" + v1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" "github.com/Azure/azure-service-operator/api/v1alpha1" azurev1alpha1 "github.com/Azure/azure-service-operator/api/v1alpha1" @@ -24,6 +27,14 @@ import ( "github.com/Azure/azure-service-operator/pkg/secrets" ) +const ( + UsernameSecretKey = "username" + PasswordSecretKey = "password" + FullyQualifiedServerNameSecretKey = "fullyQualifiedServerName" + MySQLServerNameSecretKey = "mySqlServerName" + FullyQualifiedUsernameSecretKey = "fullyQualifiedUsername" +) + // Ensure idempotently instantiates the requested server (if possible) in Azure func (m *MySQLServerClient) Ensure(ctx context.Context, obj runtime.Object, opts ...resourcemanager.ConfigOption) (bool, error) { options := &resourcemanager.Options{} @@ -41,27 +52,42 @@ func (m *MySQLServerClient) Ensure(ctx context.Context, obj runtime.Object, opts return true, err } - createmode := mysql.CreateModeDefault + createMode := mysql.CreateModeDefault if len(instance.Spec.CreateMode) != 0 { - createmode = mysql.CreateMode(instance.Spec.CreateMode) + createMode = mysql.CreateMode(instance.Spec.CreateMode) } // If a replica is requested, ensure that source server is specified - if createmode == mysql.CreateModeReplica { + if createMode == mysql.CreateModeReplica { if len(instance.Spec.ReplicaProperties.SourceServerId) == 0 { instance.Status.Message = "Replica requested but source server unspecified" return true, nil } } + adminCreds, err := m.GetUserProvidedAdminCredentials(ctx, instance) + if err != nil { + // The error already has the details we need + instance.Status.Message = err.Error() + return false, err + } + // Check to see if secret exists and if yes retrieve the admin login and password - secret, err := m.GetOrPrepareSecret(ctx, secretClient, instance) + secret, err := m.GetOrPrepareSecret(ctx, secretClient, instance, adminCreds) if err != nil { instance.Status.Message = fmt.Sprintf("Failed to get or prepare secret: %s", err.Error()) return false, err } - err = m.AddServerCredsToSecrets(ctx, secretClient, secret, instance) + // If the user didn't provide administrator credentials, get them from the secret + if adminCreds == nil { + adminCreds = &MySQLCredentials{ + username: string(secret[UsernameSecretKey]), + password: string(secret[PasswordSecretKey]), + } + } + + err = m.UpsertSecrets(ctx, secretClient, secret, instance) if err != nil { return false, err } @@ -134,8 +160,6 @@ func (m *MySQLServerClient) Ensure(ctx context.Context, obj runtime.Object, opts instance.Status.Provisioning = true instance.Status.FailedProvisioning = false - adminlogin := string(secret["username"]) - adminpassword := string(secret["password"]) skuInfo := mysql.Sku{ Name: to.StringPtr(instance.Spec.Sku.Name), Tier: mysql.SkuTier(instance.Spec.Sku.Tier), @@ -149,9 +173,9 @@ func (m *MySQLServerClient) Ensure(ctx context.Context, obj runtime.Object, opts *instance, labels, skuInfo, - adminlogin, - adminpassword, - createmode, + adminCreds.username, + adminCreds.password, + createMode, hash, ) if err != nil { @@ -288,8 +312,8 @@ func (m *MySQLServerClient) convert(obj runtime.Object) (*v1alpha2.MySQLServer, return local, nil } -// AddServerCredsToSecrets saves the server's admin credentials in the secret store -func (m *MySQLServerClient) AddServerCredsToSecrets(ctx context.Context, secretClient secrets.SecretClient, data map[string][]byte, instance *azurev1alpha2.MySQLServer) error { +// UpsertSecrets saves the server's admin credentials in the secret store +func (m *MySQLServerClient) UpsertSecrets(ctx context.Context, secretClient secrets.SecretClient, data map[string][]byte, instance *azurev1alpha2.MySQLServer) error { secretKey := secrets.SecretKey{Name: instance.Name, Namespace: instance.Namespace, Kind: instance.TypeMeta.Kind} err := secretClient.Upsert(ctx, @@ -309,7 +333,7 @@ func (m *MySQLServerClient) AddServerCredsToSecrets(ctx context.Context, secretC func (m *MySQLServerClient) UpdateServerNameInSecret(ctx context.Context, secretClient secrets.SecretClient, data map[string][]byte, fullservername string, instance *azurev1alpha2.MySQLServer) error { secretKey := secrets.SecretKey{Name: instance.Name, Namespace: instance.Namespace, Kind: instance.TypeMeta.Kind} - data["fullyQualifiedServerName"] = []byte(fullservername) + data[FullyQualifiedServerNameSecretKey] = []byte(fullservername) err := secretClient.Upsert(ctx, secretKey, @@ -325,46 +349,107 @@ func (m *MySQLServerClient) UpdateServerNameInSecret(ctx context.Context, secret } // GetOrPrepareSecret gets the admin credentials if they are stored or generates some if not -func (m *MySQLServerClient) GetOrPrepareSecret(ctx context.Context, secretClient secrets.SecretClient, instance *azurev1alpha2.MySQLServer) (map[string][]byte, error) { - createmode := instance.Spec.CreateMode - - // If createmode == default, then this is a new server creation, so generate username/password - // If createmode == replica, then get the credentials from the source server secret and use that +func (m *MySQLServerClient) GetOrPrepareSecret( + ctx context.Context, + secretClient secrets.SecretClient, + instance *azurev1alpha2.MySQLServer, + adminCredentials *MySQLCredentials) (map[string][]byte, error) { secret := map[string][]byte{} var key secrets.SecretKey var username string var password string - if strings.EqualFold(createmode, "default") { // new Mysql server creation + // If createmode == default, then this is a new server creation, so generate username/password + // If createmode == replica, then get the credentials from the source server secret and use that + if strings.EqualFold(instance.Spec.CreateMode, "default") { // new Mysql server creation // See if secret already exists and return if it does key = secrets.SecretKey{Name: instance.Name, Namespace: instance.Namespace, Kind: instance.TypeMeta.Kind} if stored, err := secretClient.Get(ctx, key); err == nil { + // Ensure that we use the most up to date secret information if the user has provided it + if adminCredentials != nil { + stored[UsernameSecretKey] = []byte(adminCredentials.username) + stored[PasswordSecretKey] = []byte(adminCredentials.password) + } return stored, nil } - // Generate random username password if secret does not exist already - username = helpers.GenerateRandomUsername(10) - password = helpers.NewPassword() - } else { // replica - sourceServerId := instance.Spec.ReplicaProperties.SourceServerId - if len(sourceServerId) != 0 { - // Parse to get source server name - sourceServerIdSplit := strings.Split(sourceServerId, "/") - sourceServer := sourceServerIdSplit[len(sourceServerIdSplit)-1] - - // Get the username and password from the source server's secret - key = secrets.SecretKey{Name: sourceServer, Namespace: instance.Namespace, Kind: instance.TypeMeta.Kind} - if sourceSecret, err := secretClient.Get(ctx, key); err == nil { - username = string(sourceSecret["username"]) - password = string(sourceSecret["password"]) + // Generate random username and password if secret does not exist already and it + // wasn't provided in the spec + if adminCredentials == nil { + username = helpers.GenerateRandomUsername(10) + password = helpers.NewPassword() + } else { + username = adminCredentials.username + password = adminCredentials.password + } + } else { + // We only attempt to get the username and password from the primary if there's not + // a user specified secret. If there IS a user specified secret then just use that. + if adminCredentials == nil { + sourceServerId := instance.Spec.ReplicaProperties.SourceServerId + if len(sourceServerId) != 0 { + // Parse to get source server name + sourceServerIdSplit := strings.Split(sourceServerId, "/") + sourceServer := sourceServerIdSplit[len(sourceServerIdSplit)-1] + + // Get the username and password from the source server's secret + key = secrets.SecretKey{Name: sourceServer, Namespace: instance.Namespace, Kind: instance.TypeMeta.Kind} + if sourceSecret, err := secretClient.Get(ctx, key); err == nil { + username = string(sourceSecret[UsernameSecretKey]) + password = string(sourceSecret[PasswordSecretKey]) + } } + } else { + username = adminCredentials.username + password = adminCredentials.password } } - // Populate secret fields - secret["username"] = []byte(username) - secret["fullyQualifiedUsername"] = []byte(fmt.Sprintf("%s@%s", username, instance.Name)) - secret["password"] = []byte(password) - secret["mySqlServerName"] = []byte(instance.Name) + // Populate secret fields derived from username and password + secret[UsernameSecretKey] = []byte(username) + secret[PasswordSecretKey] = []byte(password) + secret[FullyQualifiedUsernameSecretKey] = []byte(makeFullyQualifiedUsername(username, instance.Name)) + secret[MySQLServerNameSecretKey] = []byte(instance.Name) return secret, nil } + +// MySQLCredentials is a username/password pair for a MySQL account +type MySQLCredentials struct { + username string + password string +} + +// GetUserProvidedAdminCredentials gets the user provided MySQLCredentials, or nil if none was +// specified by the user. +func (m *MySQLServerClient) GetUserProvidedAdminCredentials( + ctx context.Context, + instance *azurev1alpha2.MySQLServer) (*MySQLCredentials, error) { + + if instance.Spec.AdminSecret == "" { + return nil, nil + } + + key := client.ObjectKey{Namespace: instance.Namespace, Name: instance.Spec.AdminSecret} + secret := &v1.Secret{} + if err := m.KubeReader.Get(ctx, key, secret); err != nil { + return nil, errors.Wrapf(err, "Failed to get AdminSecret %q", key) + } + + adminUsernameBytes, ok := secret.Data[UsernameSecretKey] + if !ok { + return nil, errors.Errorf("AdminSecret %s is missing required %q field", instance.Spec.AdminSecret, UsernameSecretKey) + } + adminPasswordBytes, ok := secret.Data[PasswordSecretKey] + if !ok { + return nil, errors.Errorf("AdminSecret %s is missing required %q field", instance.Spec.AdminSecret, PasswordSecretKey) + } + + return &MySQLCredentials{ + username: string(adminUsernameBytes), + password: string(adminPasswordBytes), + }, nil +} + +func makeFullyQualifiedUsername(username string, instanceName string) string { + return fmt.Sprintf("%s@%s", username, instanceName) +}