Skip to content

Commit

Permalink
Support user specified MySQLServer secrets
Browse files Browse the repository at this point in the history
 - 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.
  • Loading branch information
matthchr committed Jul 6, 2021
1 parent d4b0803 commit f3b7fd5
Show file tree
Hide file tree
Showing 11 changed files with 336 additions and 57 deletions.
9 changes: 9 additions & 0 deletions api/v1alpha2/mysqlserver_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
84 changes: 82 additions & 2 deletions controllers/mysql_combined_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -197,3 +199,81 @@ 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)
mySQLReplicaName := GenerateTestResourceNameWithRandom("mysql-rep", 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)

// Create a mySQL replica
mySQLReplicaInstance := v1alpha2.NewReplicaMySQLServer(mySQLReplicaName, rgName, rgLocation, mySQLServerInstance.Status.ResourceId)
mySQLReplicaInstance.Spec.StorageProfile = nil
EnsureInstance(ctx, t, tc, mySQLReplicaInstance)

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)
EnsureDelete(ctx, t, tc, mySQLReplicaInstance)
}
80 changes: 78 additions & 2 deletions controllers/mysqlserver_controller_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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()
Expand All @@ -46,3 +50,75 @@ 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()

// 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)
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)
const secretName = "mysqlserversecret"
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)
const secretName = "mysqlserversecret"
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)
}

1 change: 1 addition & 0 deletions controllers/suite_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -722,6 +722,7 @@ func setup() error {
config.GlobalCredentials(),
secretClient,
k8sManager.GetScheme(),
k8sManager.GetClient(),
),
Telemetry: telemetry.InitializeTelemetryDefault(
"MySQLServer",
Expand Down
34 changes: 28 additions & 6 deletions docs/services/mysql/mysql.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -17,15 +21,16 @@ 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 <username>@<mysqlserver>
- `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: `<username>@<mysqlserver>`.

For more information on where and how secrets are stored, look [here](/docs/secrets.md)

Expand Down Expand Up @@ -65,6 +70,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.
Expand All @@ -79,6 +90,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.
1 change: 1 addition & 0 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -710,6 +710,7 @@ func main() {
config.GlobalCredentials(),
secretClient,
mgr.GetScheme(),
mgr.GetClient(),
),
Telemetry: telemetry.InitializeTelemetryDefault(
"MySQLServer",
Expand Down
2 changes: 1 addition & 1 deletion pkg/resourcemanager/mysql/mysqlaaduser/reconcile.go
Original file line number Diff line number Diff line change
Expand Up @@ -199,7 +199,7 @@ func (m *MySQLAADUserManager) GetServer(ctx context.Context, resourceGroupName,
// 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)
client := mysqlserver.NewMySQLServerClient(m.Creds, nil, nil, nil)
return client.GetServer(ctx, resourceGroupName, serverName)
}

Expand Down
4 changes: 3 additions & 1 deletion pkg/resourcemanager/mysql/mysqluser/mysqluser.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,9 @@ 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)
// 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.NewMySQLServerClient(m.Creds, m.SecretClient, m.Scheme, nil)
return client.GetServer(ctx, resourceGroupName, serverName)
}

Expand Down
7 changes: 4 additions & 3 deletions pkg/resourcemanager/mysql/mysqluser/mysqluser_reconcile.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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,
Expand Down
11 changes: 8 additions & 3 deletions pkg/resourcemanager/mysql/server/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,26 +6,31 @@ 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/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"
"sigs.k8s.io/controller-runtime/pkg/client"
)

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,
}
}

Expand Down
Loading

0 comments on commit f3b7fd5

Please sign in to comment.