diff --git a/PROJECT b/PROJECT index 84c22ce98dd..2a80bcae005 100644 --- a/PROJECT +++ b/PROJECT @@ -53,9 +53,11 @@ resources: - group: azure version: v1alpha1 kind: PostgreSQLFirewallRule +- group: azure + kind: PostgreSQLUser + version: v1alpha1 - group: azure version: v1alpha1 - kind: APIMgmtAPI - group: azure version: v1alpha1 kind: ApimService @@ -130,4 +132,4 @@ resources: version: v1alpha1 - group: azure kind: AzureVirtualMachineExtension - version: v1alpha1 \ No newline at end of file + version: v1alpha1 diff --git a/api/v1alpha1/postgresqluser_types.go b/api/v1alpha1/postgresqluser_types.go new file mode 100644 index 00000000000..aaadba84927 --- /dev/null +++ b/api/v1alpha1/postgresqluser_types.go @@ -0,0 +1,59 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +package v1alpha1 + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// EDIT THIS FILE! THIS IS SCAFFOLDING FOR YOU TO OWN! +// NOTE: json tags are required. Any new fields you add must have json tags for the fields to be serialized. + +// PostgreSQLUserSpec defines the desired state of PostgreSqlUser +type PostgreSQLUserSpec struct { + // INSERT ADDITIONAL SPEC FIELDS - desired state of cluster + // Important: Run "make" to regenerate code after modifying this file + Server string `json:"server"` + DbName string `json:"dbName"` + ResourceGroup string `json:"resourceGroup,omitempty"` + Roles []string `json:"roles"` + // optional + AdminSecret string `json:"adminSecret,omitempty"` + AdminSecretKeyVault string `json:"adminSecretKeyVault,omitempty"` + Username string `json:"username,omitempty"` + KeyVaultToStoreSecrets string `json:"keyVaultToStoreSecrets,omitempty"` +} + +// +kubebuilder:object:root=true +// +kubebuilder:subresource:status + +// PostgreSQLUser is the Schema for the postgresqlusers API +// +kubebuilder:resource:shortName=psqlu,path=psqluser +// +kubebuilder:printcolumn:name="Provisioned",type="string",JSONPath=".status.provisioned" +// +kubebuilder:printcolumn:name="Message",type="string",JSONPath=".status.message" +type PostgreSQLUser struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + Spec PostgreSQLUserSpec `json:"spec,omitempty"` + Status ASOStatus `json:"status,omitempty"` +} + +// +kubebuilder:object:root=true + +// PostgreSQLUserList contains a list of PostgreSQLUser +type PostgreSQLUserList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []PostgreSQLUser `json:"items"` +} + +func init() { + SchemeBuilder.Register(&PostgreSQLUser{}, &PostgreSQLUserList{}) +} + +// IsSubmitted checks if psqluser is provisioning +func (s *PostgreSQLUser) IsSubmitted() bool { + return s.Status.Provisioning || s.Status.Provisioned +} diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index c4c1af59ef3..5a153bd6651 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -2890,6 +2890,85 @@ func (in *PostgreSQLServerSpec) DeepCopy() *PostgreSQLServerSpec { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *PostgreSQLUser) DeepCopyInto(out *PostgreSQLUser) { + *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 PostgreSQLUser. +func (in *PostgreSQLUser) DeepCopy() *PostgreSQLUser { + if in == nil { + return nil + } + out := new(PostgreSQLUser) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *PostgreSQLUser) 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 *PostgreSQLUserList) DeepCopyInto(out *PostgreSQLUserList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]PostgreSQLUser, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PostgreSQLUserList. +func (in *PostgreSQLUserList) DeepCopy() *PostgreSQLUserList { + if in == nil { + return nil + } + out := new(PostgreSQLUserList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *PostgreSQLUserList) 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 *PostgreSQLUserSpec) DeepCopyInto(out *PostgreSQLUserSpec) { + *out = *in + if in.Roles != nil { + in, out := &in.Roles, &out.Roles + *out = make([]string, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PostgreSQLUserSpec. +func (in *PostgreSQLUserSpec) DeepCopy() *PostgreSQLUserSpec { + if in == nil { + return nil + } + out := new(PostgreSQLUserSpec) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *PostgreSQLVNetRule) DeepCopyInto(out *PostgreSQLVNetRule) { *out = *in diff --git a/charts/azure-service-operator-0.1.0.tgz b/charts/azure-service-operator-0.1.0.tgz index ec2639d3022..35192b2ec5b 100644 Binary files a/charts/azure-service-operator-0.1.0.tgz and b/charts/azure-service-operator-0.1.0.tgz differ diff --git a/charts/index.yaml b/charts/index.yaml index 7f94240817a..f5c13bc714d 100644 --- a/charts/index.yaml +++ b/charts/index.yaml @@ -3,14 +3,14 @@ entries: azure-service-operator: - apiVersion: v2 appVersion: 0.1.0 - created: "2020-06-01T12:41:48.174129-06:00" + created: "2020-06-02T09:40:24.98057+08:00" dependencies: - condition: azureUseMI name: aad-pod-identity repository: https://raw.githubusercontent.com/Azure/aad-pod-identity/master/charts version: 1.5.5 description: Deploy components and dependencies of azure-service-operator - digest: ec95181a3e59179bfdb3bd92989613b1469104a39e6054acea90382795b1fe2d + digest: b549a78f07f6dca8a8f761cd65791ca8cad73b3bf90b03891101fa2cd70e3de8 home: https://github.com/Azure/azure-service-operator name: azure-service-operator sources: @@ -18,4 +18,4 @@ entries: urls: - azure-service-operator-0.1.0.tgz version: 0.1.0 -generated: "2020-06-01T12:41:48.169188-06:00" +generated: "2020-06-02T09:40:24.976336+08:00" diff --git a/config/crd/kustomization.yaml b/config/crd/kustomization.yaml index 55988cf27b4..a7eecb03ff2 100644 --- a/config/crd/kustomization.yaml +++ b/config/crd/kustomization.yaml @@ -22,6 +22,7 @@ resources: - bases/azure.microsoft.com_postgresqldatabases.yaml - bases/azure.microsoft.com_postgresqlfirewallrules.yaml - bases/azure.microsoft.com_postgresqlvnetrules.yaml +- bases/azure.microsoft.com_postgresqlusers.yaml - bases/azure.microsoft.com_apimservices.yaml - bases/azure.microsoft.com_apimgmtapis.yaml - bases/azure.microsoft.com_virtualnetworks.yaml diff --git a/config/crd/patches/cainjection_in_postgresqlusers.yaml b/config/crd/patches/cainjection_in_postgresqlusers.yaml new file mode 100644 index 00000000000..d2c81e8115c --- /dev/null +++ b/config/crd/patches/cainjection_in_postgresqlusers.yaml @@ -0,0 +1,8 @@ +# The following patch adds a directive for certmanager to inject CA into the CRD +# CRD conversion requires k8s 1.13 or later. +apiVersion: apiextensions.k8s.io/v1beta1 +kind: CustomResourceDefinition +metadata: + annotations: + cert-manager.io/inject-ca-from: $(CERTIFICATE_NAMESPACE)/$(CERTIFICATE_NAME) + name: psqlusers.azure.microsoft.com diff --git a/config/crd/patches/webhook_in_postgresqlusers.yaml b/config/crd/patches/webhook_in_postgresqlusers.yaml new file mode 100644 index 00000000000..61610ffbb4a --- /dev/null +++ b/config/crd/patches/webhook_in_postgresqlusers.yaml @@ -0,0 +1,17 @@ +# The following patch enables conversion webhook for CRD +# CRD conversion requires k8s 1.13 or later. +apiVersion: apiextensions.k8s.io/v1beta1 +kind: CustomResourceDefinition +metadata: + name: postgresqlusers.azure.microsoft.com +spec: + conversion: + strategy: Webhook + webhookClientConfig: + # this is "\n" used as a placeholder, otherwise it will be rejected by the apiserver for being blank, + # but we're going to set it later using the cert-manager (or potentially a patch if not using cert-manager) + caBundle: Cg== + service: + namespace: system + name: webhook-service + path: /convert diff --git a/config/rbac/postgresqluser_editor_role.yaml b/config/rbac/postgresqluser_editor_role.yaml new file mode 100644 index 00000000000..fcb256f4671 --- /dev/null +++ b/config/rbac/postgresqluser_editor_role.yaml @@ -0,0 +1,27 @@ +# permissions to do edit mysqlusers. +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: postgresqluser-editor-role +rules: +- apiGroups: + - azure.microsoft.com + resources: + - postgresqlusers + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - azure.microsoft.com + resources: + - postgre + sqlusers/status + verbs: + - get + - patch + - update diff --git a/config/rbac/postgresqluser_viewer_role.yaml b/config/rbac/postgresqluser_viewer_role.yaml new file mode 100644 index 00000000000..2cceadb4b0c --- /dev/null +++ b/config/rbac/postgresqluser_viewer_role.yaml @@ -0,0 +1,20 @@ +# permissions to do viewer mysqlusers. +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: postgresqluser-viewer-role +rules: +- apiGroups: + - azure.microsoft.com + resources: + - postgresqlusers + verbs: + - get + - list + - watch +- apiGroups: + - azure.microsoft.com + resources: + - postgresqlusers/status + verbs: + - get diff --git a/config/samples/azure_v1alpha1_postgresqluser.yaml b/config/samples/azure_v1alpha1_postgresqluser.yaml new file mode 100644 index 00000000000..925151ecd47 --- /dev/null +++ b/config/samples/azure_v1alpha1_postgresqluser.yaml @@ -0,0 +1,27 @@ +apiVersion: azure.microsoft.com/v1alpha1 +kind: PostgreSQLUser +metadata: + name: psqluser-sample +spec: + server: postgresqlserver-sample + dbName: postgresqldatabase-sample + resourceGroup: resourcegroup-azure-operators + # The Azure Database for PostgreSQL server is created with the 3 default roles defined. + # azure_pg_admin + # azure_superuser + # your server admin user + roles: + - "azure_pg_admin" + # Specify a specific username for the user + # username: psqluser-sample + # Specify adminSecret and adminSecretKeyVault if you want to + # read the PSQL server admin creds from a specific keyvault secret + # adminSecret: postgresqlserver-sample + # adminSecretKeyVault: asokeyvault + + # Use the field below to optionally specify a different keyvault + # to store the secrets in + # keyVaultToStoreSecrets: asokeyvault + + + diff --git a/controllers/postgresql_combined_controller_test.go b/controllers/postgresql_combined_controller_test.go index d4700ba5c07..acb8feed8b4 100644 --- a/controllers/postgresql_combined_controller_test.go +++ b/controllers/postgresql_combined_controller_test.go @@ -61,6 +61,24 @@ func TestPSQLDatabaseController(t *testing.T) { EnsureInstance(ctx, t, tc, postgreSQLServerInstance) + postgreSQLFirewallRuleName := GenerateTestResourceNameWithRandom("psql-fwrule", 10) + + // Create the PostgreSQLFirewallRule object and expect the Reconcile to be created + postgreSQLFirewallRuleInstance := &azurev1alpha1.PostgreSQLFirewallRule{ + ObjectMeta: metav1.ObjectMeta{ + Name: postgreSQLFirewallRuleName, + Namespace: "default", + }, + Spec: azurev1alpha1.PostgreSQLFirewallRuleSpec{ + ResourceGroup: rgName, + Server: postgreSQLServerName, + StartIPAddress: "0.0.0.0", + EndIPAddress: "255.255.255.255", + }, + } + + EnsureInstance(ctx, t, tc, postgreSQLFirewallRuleInstance) + postgreSQLDatabaseName := GenerateTestResourceNameWithRandom("psql-db", 10) // Create the PostgreSQLDatabase object and expect the Reconcile to be created @@ -79,26 +97,6 @@ func TestPSQLDatabaseController(t *testing.T) { EnsureDelete(ctx, t, tc, postgreSQLDatabaseInstance) - // Test firewall rule ------------------------------- - - postgreSQLFirewallRuleName := GenerateTestResourceNameWithRandom("psql-fwrule", 10) - - // Create the PostgreSQLFirewallRule object and expect the Reconcile to be created - postgreSQLFirewallRuleInstance := &azurev1alpha1.PostgreSQLFirewallRule{ - ObjectMeta: metav1.ObjectMeta{ - Name: postgreSQLFirewallRuleName, - Namespace: "default", - }, - Spec: azurev1alpha1.PostgreSQLFirewallRuleSpec{ - ResourceGroup: rgName, - Server: postgreSQLServerName, - StartIPAddress: "0.0.0.0", - EndIPAddress: "0.0.0.0", - }, - } - - EnsureInstance(ctx, t, tc, postgreSQLFirewallRuleInstance) - EnsureDelete(ctx, t, tc, postgreSQLFirewallRuleInstance) EnsureDelete(ctx, t, tc, postgreSQLServerInstance) diff --git a/controllers/postgresqluser_controller.go b/controllers/postgresqluser_controller.go new file mode 100644 index 00000000000..9da8f196564 --- /dev/null +++ b/controllers/postgresqluser_controller.go @@ -0,0 +1,29 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +package controllers + +import ( + ctrl "sigs.k8s.io/controller-runtime" + + azurev1alpha1 "github.com/Azure/azure-service-operator/api/v1alpha1" +) + +// PostgreSQLUserReconciler reconciles a PSQLUser object +type PostgreSQLUserReconciler struct { + Reconciler *AsyncReconciler +} + +//Reconcile for postgresqluser +// +kubebuilder:rbac:groups=azure.microsoft.com,resources=postgresqlusers,verbs=get;list;watch;create;update;patch;delete +// +kubebuilder:rbac:groups=azure.microsoft.com,resources=postgresqlusers/status,verbs=get;update;patch +func (r *PostgreSQLUserReconciler) Reconcile(req ctrl.Request) (ctrl.Result, error) { + return r.Reconciler.Reconcile(req, &azurev1alpha1.PostgreSQLUser{}) +} + +// SetupWithManager runs reconcile loop with manager +func (r *PostgreSQLUserReconciler) SetupWithManager(mgr ctrl.Manager) error { + return ctrl.NewControllerManagedBy(mgr). + For(&azurev1alpha1.PostgreSQLUser{}). + Complete(r) +} diff --git a/controllers/postgresqluser_controller_test.go b/controllers/postgresqluser_controller_test.go new file mode 100644 index 00000000000..a169efa10b3 --- /dev/null +++ b/controllers/postgresqluser_controller_test.go @@ -0,0 +1,102 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +// +build all postgresqluser + +package controllers + +import ( + "context" + "testing" + + azurev1alpha1 "github.com/Azure/azure-service-operator/api/v1alpha1" + "github.com/Azure/azure-service-operator/pkg/errhelp" + "github.com/Azure/azure-service-operator/pkg/helpers" + "github.com/stretchr/testify/assert" + v1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func TestPostgreSQLUserControllerNoAdminSecret(t *testing.T) { + t.Parallel() + defer PanicRecover(t) + ctx := context.Background() + + var postgresqlServerName string + var postgresqlDatabaseName string + var postgresqlUser *azurev1alpha1.PostgreSQLUser + + postgresqlServerName = GenerateTestResourceNameWithRandom("psqlserver-test", 10) + postgresqlDatabaseName = GenerateTestResourceNameWithRandom("psqldb-test", 10) + pusername := "psql-test-user" + helpers.RandomString(10) + roles := []string{"azure_pg_admin"} + + postgresqlUser = &azurev1alpha1.PostgreSQLUser{ + ObjectMeta: metav1.ObjectMeta{ + Name: pusername, + Namespace: "default", + }, + Spec: azurev1alpha1.PostgreSQLUserSpec{ + Server: postgresqlServerName, + DbName: postgresqlDatabaseName, + AdminSecret: "", + Roles: roles, + }, + } + + EnsureInstanceWithResult(ctx, t, tc, postgresqlUser, "admin secret", false) + + EnsureDelete(ctx, t, tc, postgresqlUser) +} + +func TestPostgreSQLUserControllerNoResourceGroup(t *testing.T) { + t.Parallel() + defer PanicRecover(t) + ctx := context.Background() + assert := assert.New(t) + var err error + var psqlServerName string + var psqlDatabaseName string + var psqlUser *azurev1alpha1.PostgreSQLUser + + psqlServerName = GenerateTestResourceNameWithRandom("psqlserver-test", 10) + psqlDatabaseName = GenerateTestResourceNameWithRandom("psqldb-test", 10) + pusername := "psql-test-user" + helpers.RandomString(10) + roles := []string{"azure_pg_admin"} + + secret := &v1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: psqlServerName, + Namespace: "default", + }, + // Needed to avoid nil map error + Data: map[string][]byte{ + "username": []byte("username"), + "password": []byte("password"), + }, + Type: "Opaque", + } + + // Create the sqlUser + err = tc.k8sClient.Create(ctx, secret) + assert.Equal(nil, err, "create admin secret in k8s") + + psqlUser = &azurev1alpha1.PostgreSQLUser{ + ObjectMeta: metav1.ObjectMeta{ + Name: pusername, + Namespace: "default", + }, + Spec: azurev1alpha1.PostgreSQLUserSpec{ + Server: psqlServerName, + DbName: psqlDatabaseName, + AdminSecret: "", + Roles: roles, + ResourceGroup: "fakerg" + helpers.RandomString(10), + }, + } + + EnsureInstanceWithResult(ctx, t, tc, psqlUser, errhelp.ResourceGroupNotFoundErrorCode, false) + + EnsureDelete(ctx, t, tc, psqlUser) + +} diff --git a/controllers/suite_test.go b/controllers/suite_test.go index b6459364ee0..44d6325e58a 100644 --- a/controllers/suite_test.go +++ b/controllers/suite_test.go @@ -44,6 +44,7 @@ import ( resourcemanagerpip "github.com/Azure/azure-service-operator/pkg/resourcemanager/pip" resourcemanagerpsqldatabase "github.com/Azure/azure-service-operator/pkg/resourcemanager/psql/database" resourcemanagerpsqlfirewallrule "github.com/Azure/azure-service-operator/pkg/resourcemanager/psql/firewallrule" + resourcemanagerpsqluser "github.com/Azure/azure-service-operator/pkg/resourcemanager/psql/psqluser" resourcemanagerpsqlserver "github.com/Azure/azure-service-operator/pkg/resourcemanager/psql/server" rediscacheactions "github.com/Azure/azure-service-operator/pkg/resourcemanager/rediscaches/actions" rcfwr "github.com/Azure/azure-service-operator/pkg/resourcemanager/rediscaches/firewallrule" @@ -756,6 +757,22 @@ func setup() error { return err } + err = (&PostgreSQLUserReconciler{ + Reconciler: &AsyncReconciler{ + Client: k8sManager.GetClient(), + AzureClient: resourcemanagerpsqluser.NewPostgreSqlUserManager(secretClient, k8sManager.GetScheme()), + Telemetry: telemetry.InitializeTelemetryDefault( + "PostgreSQLUser", + ctrl.Log.WithName("controllers").WithName("PostgreSQLUser"), + ), + Recorder: k8sManager.GetEventRecorderFor("PostgreSQLUser-controller"), + Scheme: k8sManager.GetScheme(), + }, + }).SetupWithManager(k8sManager) + if err != nil { + return err + } + err = (&StorageAccountReconciler{ Reconciler: &AsyncReconciler{ Client: k8sManager.GetClient(), diff --git a/docs/howto/newoperatorguide.md b/docs/howto/newoperatorguide.md index caa1e2ed48a..a3d9748f446 100644 --- a/docs/howto/newoperatorguide.md +++ b/docs/howto/newoperatorguide.md @@ -321,7 +321,7 @@ go build -o bin/manager main.go make install ``` - Run controller tests using `make test-existing-controllers` and deploy using `make deploy` + Run controller tests using `make test-integration-controllers` and deploy using `make deploy` If you make changes to the operator and want to update the deployment without recreating the cluster (when testing locally), you can use the `make update` to update your Azure Operator pod. If you need to rebuild the docker image without cache, use `make ARGS="--no-cache" update` diff --git a/main.go b/main.go index e8e667805c1..3104ff2fcb8 100644 --- a/main.go +++ b/main.go @@ -47,6 +47,7 @@ import ( pip "github.com/Azure/azure-service-operator/pkg/resourcemanager/pip" psqldatabase "github.com/Azure/azure-service-operator/pkg/resourcemanager/psql/database" psqlfirewallrule "github.com/Azure/azure-service-operator/pkg/resourcemanager/psql/firewallrule" + psqluser "github.com/Azure/azure-service-operator/pkg/resourcemanager/psql/psqluser" psqlserver "github.com/Azure/azure-service-operator/pkg/resourcemanager/psql/server" psqlvnetrule "github.com/Azure/azure-service-operator/pkg/resourcemanager/psql/vnetrule" rediscacheactions "github.com/Azure/azure-service-operator/pkg/resourcemanager/rediscaches/actions" @@ -179,6 +180,10 @@ func main() { psqlserverclient := psqlserver.NewPSQLServerClient(secretClient, mgr.GetScheme()) psqldatabaseclient := psqldatabase.NewPSQLDatabaseClient() psqlfirewallruleclient := psqlfirewallrule.NewPSQLFirewallRuleClient() + psqlusermanager := psqluser.NewPostgreSqlUserManager( + secretClient, + scheme, + ) sqlUserManager := resourcemanagersqluser.NewAzureSqlUserManager( secretClient, scheme, @@ -578,6 +583,22 @@ func main() { os.Exit(1) } + if err = (&controllers.PostgreSQLUserReconciler{ + Reconciler: &controllers.AsyncReconciler{ + Client: mgr.GetClient(), + AzureClient: psqlusermanager, + Telemetry: telemetry.InitializeTelemetryDefault( + "PSQLUser", + ctrl.Log.WithName("controllers").WithName("PostgreSQLUser"), + ), + Recorder: mgr.GetEventRecorderFor("PostgreSQLUser-controller"), + Scheme: scheme, + }, + }).SetupWithManager(mgr); err != nil { + setupLog.Error(err, "unable to create controller", "controller", "PostgreSQLUser") + os.Exit(1) + } + if err = (&controllers.ApimServiceReconciler{ Reconciler: &controllers.AsyncReconciler{ Client: mgr.GetClient(), diff --git a/pkg/helpers/stringhelper.go b/pkg/helpers/stringhelper.go index e223f68948e..aad3705d718 100644 --- a/pkg/helpers/stringhelper.go +++ b/pkg/helpers/stringhelper.go @@ -189,3 +189,21 @@ func FromBase64EncodedString(input string) string { decodedString := string(output) return decodedString } + +// FindBadChars find the bad chars in a postgresql user +func FindBadChars(stack string) error { + badChars := []string{ + "'", + "\"", + ";", + "--", + "/*", + } + + for _, s := range badChars { + if idx := strings.Index(stack, s); idx > -1 { + return fmt.Errorf("potentially dangerous character seqience found: '%s' at pos: %d", s, idx) + } + } + return nil +} diff --git a/pkg/resourcemanager/psql/database/database.go b/pkg/resourcemanager/psql/database/database.go index 5f39c108ae8..18c686f7754 100644 --- a/pkg/resourcemanager/psql/database/database.go +++ b/pkg/resourcemanager/psql/database/database.go @@ -19,7 +19,8 @@ func NewPSQLDatabaseClient() *PSQLDatabaseClient { return &PSQLDatabaseClient{} } -func getPSQLDatabasesClient() (psql.DatabasesClient, error) { +//GetPSQLDatabasesClient retrieves the psqldabase +func GetPSQLDatabasesClient() (psql.DatabasesClient, error) { databasesClient := psql.NewDatabasesClientWithBaseURI(config.BaseURI(), config.SubscriptionID()) a, err := iam.GetResourceManagementAuthorizer() if err != nil { @@ -64,7 +65,7 @@ func (p *PSQLDatabaseClient) CheckDatabaseNameAvailability(ctx context.Context, func (p *PSQLDatabaseClient) CreateDatabaseIfValid(ctx context.Context, databasename string, servername string, resourcegroup string) (*http.Response, error) { - client, err := getPSQLDatabasesClient() + client, err := GetPSQLDatabasesClient() if err != nil { return &http.Response{ StatusCode: 500, @@ -99,7 +100,7 @@ func (p *PSQLDatabaseClient) CreateDatabaseIfValid(ctx context.Context, database func (p *PSQLDatabaseClient) DeleteDatabase(ctx context.Context, databasename string, servername string, resourcegroup string) (status string, err error) { - client, err := getPSQLDatabasesClient() + client, err := GetPSQLDatabasesClient() if err != nil { return "", err } @@ -116,7 +117,7 @@ func (p *PSQLDatabaseClient) DeleteDatabase(ctx context.Context, databasename st func (p *PSQLDatabaseClient) GetDatabase(ctx context.Context, resourcegroup string, servername string, databasename string) (db psql.Database, err error) { - client, err := getPSQLDatabasesClient() + client, err := GetPSQLDatabasesClient() if err != nil { return psql.Database{}, err } diff --git a/pkg/resourcemanager/psql/psqluser/psqluser.go b/pkg/resourcemanager/psql/psqluser/psqluser.go new file mode 100644 index 00000000000..8a173f0efa7 --- /dev/null +++ b/pkg/resourcemanager/psql/psqluser/psqluser.go @@ -0,0 +1,223 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +package psqluser + +import ( + "context" + "database/sql" + "fmt" + "strings" + + psql "github.com/Azure/azure-sdk-for-go/services/postgresql/mgmt/2017-12-01/postgresql" + "github.com/Azure/azure-service-operator/pkg/helpers" + "github.com/Azure/azure-service-operator/pkg/resourcemanager/config" + psdatabase "github.com/Azure/azure-service-operator/pkg/resourcemanager/psql/database" + "github.com/Azure/azure-service-operator/pkg/secrets" + + "github.com/Azure/azure-service-operator/api/v1alpha1" + "k8s.io/apimachinery/pkg/runtime" + + _ "github.com/lib/pq" //the pg lib + "k8s.io/apimachinery/pkg/types" +) + +// PSqlServerPort is the default server port for sql server +const PSqlServerPort = 5432 + +// PDriverName is driver name for psqldb connection +const PDriverName = "postgres" + +// PSecretUsernameKey is the username key in secret +const PSecretUsernameKey = "username" + +// PSecretPasswordKey is the password key in secret +const PSecretPasswordKey = "password" + +//PostgreSqlUserManager for psqluser manager +type PostgreSqlUserManager struct { + SecretClient secrets.SecretClient + Scheme *runtime.Scheme +} + +//NewPostgreSqlUserManager creates a new PostgreSqlUserManager +func NewPostgreSqlUserManager(secretClient secrets.SecretClient, scheme *runtime.Scheme) *PostgreSqlUserManager { + return &PostgreSqlUserManager{ + SecretClient: secretClient, + Scheme: scheme, + } +} + +// GetDB retrieves a database +func (s *PostgreSqlUserManager) GetDB(ctx context.Context, resourceGroupName string, serverName string, databaseName string) (db psql.Database, err error) { + dbClient, err := psdatabase.GetPSQLDatabasesClient() + if err != nil { + return psql.Database{}, err + } + + return dbClient.Get( + ctx, + resourceGroupName, + serverName, + databaseName, + ) +} + +// ConnectToSqlDb connects to the PostgreSQL db using the given credentials +func (s *PostgreSqlUserManager) ConnectToSqlDb(ctx context.Context, drivername string, fullservername string, database string, port int, user string, password string) (*sql.DB, error) { + + connString := fmt.Sprintf("host=%s user=%s password=%s port=%d dbname=%s sslmode=require connect_timeout=30", fullservername, user, password, port, database) + + db, err := sql.Open(drivername, connString) + if err != nil { + return db, err + } + + err = db.PingContext(ctx) + if err != nil { + return db, err + } + + return db, err +} + +// GrantUserRoles grants roles to a user for a given database +func (s *PostgreSqlUserManager) GrantUserRoles(ctx context.Context, user string, roles []string, db *sql.DB) error { + var errorStrings []string + + if err := helpers.FindBadChars(user); err != nil { + return fmt.Errorf("Problem found with username: %v", err) + } + + for _, role := range roles { + tsql := fmt.Sprintf("GRANT %s TO %q", role, user) + + if err := helpers.FindBadChars(role); err != nil { + return fmt.Errorf("Problem found with role: %v", err) + } + + _, err := db.ExecContext(ctx, tsql) + if err != nil { + errorStrings = append(errorStrings, err.Error()) + } + } + + if len(errorStrings) != 0 { + return fmt.Errorf(strings.Join(errorStrings, "\n")) + } + return nil +} + +// CreateUser creates user with secret credentials +func (s *PostgreSqlUserManager) CreateUser(ctx context.Context, secret map[string][]byte, db *sql.DB) (string, error) { + newUser := string(secret[PSecretUsernameKey]) + newPassword := string(secret[PSecretPasswordKey]) + + // make an effort to prevent sql injection + if err := helpers.FindBadChars(newUser); err != nil { + return "", fmt.Errorf("Problem found with username: %v", err) + } + if err := helpers.FindBadChars(newPassword); err != nil { + return "", fmt.Errorf("Problem found with password: %v", err) + } + + tsql := fmt.Sprintf("CREATE USER \"%s\" WITH PASSWORD '%s'", newUser, newPassword) + _, err := db.ExecContext(ctx, tsql) + + if err != nil { + return newUser, err + } + return newUser, nil +} + +// UpdateUser - Updates user password +func (s *PostgreSqlUserManager) UpdateUser(ctx context.Context, secret map[string][]byte, db *sql.DB) error { + user := string(secret[PSecretUsernameKey]) + newPassword := helpers.NewPassword() + + // make an effort to prevent sql injection + if err := helpers.FindBadChars(user); err != nil { + return fmt.Errorf("Problem found with username: %v", err) + } + if err := helpers.FindBadChars(newPassword); err != nil { + return fmt.Errorf("Problem found with password: %v", err) + } + + tsql := fmt.Sprintf("ALTER USER '%s' WITH PASSWORD '%s'", user, newPassword) + _, err := db.ExecContext(ctx, tsql) + + return err +} + +// UserExists checks if db contains user +func (s *PostgreSqlUserManager) UserExists(ctx context.Context, db *sql.DB, username string) (bool, error) { + + res, err := db.ExecContext(ctx, "SELECT * FROM pg_user WHERE usename = $1", username) + if err != nil { + return false, err + } + rows, err := res.RowsAffected() + return rows > 0, err + +} + +// DropUser drops a user from db +func (s *PostgreSqlUserManager) DropUser(ctx context.Context, db *sql.DB, user string) error { + if err := helpers.FindBadChars(user); err != nil { + return fmt.Errorf("Problem found with username: %v", err) + } + + tsql := fmt.Sprintf("DROP USER IF EXISTS %q", user) + _, err := db.ExecContext(ctx, tsql) + return err +} + +// DeleteSecrets deletes the secrets associated with a SQLUser +func (s *PostgreSqlUserManager) DeleteSecrets(ctx context.Context, instance *v1alpha1.PostgreSQLUser, secretClient secrets.SecretClient) (bool, error) { + + secretKey := GetNamespacedName(instance, secretClient) + + // delete standard user secret + err := secretClient.Delete( + ctx, + secretKey, + ) + if err != nil { + instance.Status.Message = "failed to delete secret, err: " + err.Error() + return false, err + } + + return false, nil +} + +// GetOrPrepareSecret gets or creates a secret +func (s *PostgreSqlUserManager) GetOrPrepareSecret(ctx context.Context, instance *v1alpha1.PostgreSQLUser, secretClient secrets.SecretClient) map[string][]byte { + key := GetNamespacedName(instance, secretClient) + + secret, err := secretClient.Get(ctx, key) + + psqldbdnssuffix := "postgres.database.azure.com" + if config.Environment().Name != "AzurePublicCloud" { + psqldbdnssuffix = "postgres." + config.Environment().SQLDatabaseDNSSuffix + } + + if err != nil { + // @todo: find out whether this is an error due to non existing key or failed conn + pw := helpers.NewPassword() + return map[string][]byte{ + "username": []byte(""), + "password": []byte(pw), + "PSqlServerNamespace": []byte(instance.Namespace), + "PSqlServerName": []byte(instance.Spec.Server), + "fullyQualifiedServerName": []byte(instance.Spec.Server + "." + psqldbdnssuffix), + "PSqlDatabaseName": []byte(instance.Spec.DbName), + } + } + + return secret +} + +// GetNamespacedName gets the namespaced-name +func GetNamespacedName(instance *v1alpha1.PostgreSQLUser, secretClient secrets.SecretClient) types.NamespacedName { + return types.NamespacedName{Name: instance.Name, Namespace: instance.Namespace} +} diff --git a/pkg/resourcemanager/psql/psqluser/psqluser_manager.go b/pkg/resourcemanager/psql/psqluser/psqluser_manager.go new file mode 100644 index 00000000000..c39d85743a6 --- /dev/null +++ b/pkg/resourcemanager/psql/psqluser/psqluser_manager.go @@ -0,0 +1,29 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +package psqluser + +import ( + "context" + "database/sql" + + psql "github.com/Azure/azure-sdk-for-go/services/postgresql/mgmt/2017-12-01/postgresql" + + "github.com/Azure/azure-service-operator/api/v1alpha1" + "github.com/Azure/azure-service-operator/pkg/resourcemanager" + "github.com/Azure/azure-service-operator/pkg/secrets" +) + +type PSqlUserManager interface { + GetDB(ctx context.Context, resourceGroupName string, serverName string, databaseName string) (psql.Database, error) + ConnectToPostgreSqlDb(ctx context.Context, drivername string, server string, dbname string, port int, username string, password string) (*sql.DB, error) + GrantUserRoles(ctx context.Context, user string, roles []string, db *sql.DB) error + CreateUser(ctx context.Context, secret map[string][]byte, db *sql.DB) (string, error) + UserExists(ctx context.Context, db *sql.DB, username string) (bool, error) + DropUser(ctx context.Context, db *sql.DB, user string) error + DeleteSecrets(ctx context.Context, instance *v1alpha1.PostgreSQLUser, secretClient secrets.SecretClient) (bool, error) + GetOrPrepareSecret(ctx context.Context, instance *v1alpha1.PostgreSQLUser, secretClient secrets.SecretClient) map[string][]byte + + // also embed methods from AsyncClient + resourcemanager.ARMClient +} diff --git a/pkg/resourcemanager/psql/psqluser/psqluser_reconcile.go b/pkg/resourcemanager/psql/psqluser/psqluser_reconcile.go new file mode 100644 index 00000000000..897a662a895 --- /dev/null +++ b/pkg/resourcemanager/psql/psqluser/psqluser_reconcile.go @@ -0,0 +1,342 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +package psqluser + +import ( + "context" + "encoding/json" + "fmt" + "reflect" + "strings" + + "github.com/Azure/azure-service-operator/pkg/helpers" + "github.com/Azure/azure-service-operator/pkg/secrets" + + "github.com/Azure/azure-service-operator/api/v1alpha1" + "github.com/Azure/azure-service-operator/api/v1alpha2" + "github.com/Azure/azure-service-operator/pkg/errhelp" + "github.com/Azure/azure-service-operator/pkg/resourcemanager" + keyvaultSecrets "github.com/Azure/azure-service-operator/pkg/secrets/keyvault" + + _ "github.com/lib/pq" //pglib + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" +) + +// Ensure that user exists +func (s *PostgreSqlUserManager) Ensure(ctx context.Context, obj runtime.Object, opts ...resourcemanager.ConfigOption) (bool, error) { + instance, err := s.convert(obj) + if err != nil { + return false, err + } + + requestedUsername := instance.Spec.Username + if len(requestedUsername) == 0 { + requestedUsername = instance.Name + } + + options := &resourcemanager.Options{} + for _, opt := range opts { + opt(options) + } + + adminSecretClient := s.SecretClient + + adminsecretName := instance.Spec.AdminSecret + if len(instance.Spec.AdminSecret) == 0 { + adminsecretName = instance.Spec.Server + } + + key := types.NamespacedName{Name: adminsecretName, Namespace: instance.Namespace} + + var sqlUserSecretClient secrets.SecretClient + if options.SecretClient != nil { + sqlUserSecretClient = options.SecretClient + } else { + sqlUserSecretClient = s.SecretClient + } + + // if the admin secret keyvault is not specified, fall back to global secretclient + if len(instance.Spec.AdminSecretKeyVault) != 0 { + adminSecretClient = keyvaultSecrets.New(instance.Spec.AdminSecretKeyVault) + if len(instance.Spec.AdminSecret) != 0 { + key = types.NamespacedName{Name: instance.Spec.AdminSecret} + } + } + + // get admin creds for server + adminSecret, err := adminSecretClient.Get(ctx, key) + if err != nil { + instance.Status.Provisioning = false + instance.Status.Message = fmt.Sprintf("admin secret : %s, not found in %s", key.String(), reflect.TypeOf(adminSecretClient).Elem().Name()) + return false, nil + } + + adminUser := string(adminSecret["fullyQualifiedUsername"]) + adminPassword := string(adminSecret[PSecretPasswordKey]) + + _, err = s.GetDB(ctx, instance.Spec.ResourceGroup, instance.Spec.Server, instance.Spec.DbName) + if err != nil { + instance.Status.Message = errhelp.StripErrorIDs(err) + instance.Status.Provisioning = false + + requeuErrors := []string{ + errhelp.ResourceNotFound, + errhelp.ParentNotFoundErrorCode, + errhelp.ResourceGroupNotFoundErrorCode, + } + azerr := errhelp.NewAzureErrorAzureError(err) + if helpers.ContainsString(requeuErrors, azerr.Type) { + return false, nil + } + + // if the database is busy, requeue + errorString := err.Error() + if strings.Contains(errorString, "Please retry the connection later") { + return false, nil + } + + // if this is an unmarshall error - ignore and continue, otherwise report error and requeue + if _, ok := err.(*json.UnmarshalTypeError); ok { + return false, nil + } + } + + fullServerName := string(adminSecret["fullyQualifiedServerName"]) + db, err := s.ConnectToSqlDb(ctx, PDriverName, fullServerName, instance.Spec.DbName, PSqlServerPort, adminUser, adminPassword) + if err != nil { + instance.Status.Message = errhelp.StripErrorIDs(err) + instance.Status.Provisioning = false + + // catch firewall issue - keep cycling until it clears up + if strings.Contains(err.Error(), "no pg_hba.conf entry for host") { + instance.Status.Message = errhelp.StripErrorIDs(err) + "\nThe IP address is not allowed to access the server. Modify the firewall rule to include the IP address." + return false, nil + } + + // if the database is busy, requeue + errorString := err.Error() + if strings.Contains(errorString, "Please retry the connection later") { + return false, nil + } + + return false, err + } + + // determine our key namespace - if we're persisting to kube, we should use the actual instance namespace. + // In keyvault we have to avoid collisions with other secrets so we create a custom namespace with the user's parameters + key = GetNamespacedName(instance, sqlUserSecretClient) + + // create or get new user secret + DBSecret := s.GetOrPrepareSecret(ctx, instance, sqlUserSecretClient) + // reset user from secret in case it was loaded + user := string(DBSecret[PSecretUsernameKey]) + if user == "" { + user = fmt.Sprintf(requestedUsername) + DBSecret[PSecretUsernameKey] = []byte(user) + } + + // Publishing the user secret: + // We do this first so if the keyvault does not have right permissions we will not proceed to creating the user + err = sqlUserSecretClient.Upsert( + ctx, + key, + DBSecret, + secrets.WithOwner(instance), + secrets.WithScheme(s.Scheme), + ) + if err != nil { + instance.Status.Message = "failed to update secret, err: " + err.Error() + return false, err + } + + userExists, err := s.UserExists(ctx, db, string(DBSecret[PSecretUsernameKey])) + if err != nil { + instance.Status.Message = fmt.Sprintf("failed checking for user, err: %v", err) + return false, nil + } + + if !userExists { + user, err = s.CreateUser(ctx, DBSecret, db) + if err != nil { + instance.Status.Message = "failed creating user, err: " + err.Error() + return false, err + } + } + + // apply roles to user + if len(instance.Spec.Roles) == 0 { + instance.Status.Message = "No roles specified for user" + return false, fmt.Errorf("No roles specified for database user") + } + + err = s.GrantUserRoles(ctx, user, instance.Spec.Roles, db) + if err != nil { + instance.Status.Message = "GrantUserRoles failed" + return false, fmt.Errorf("GrantUserRoles failed") + } + + instance.Status.Provisioned = true + instance.Status.State = "Succeeded" + instance.Status.Message = resourcemanager.SuccessMsg + // reconcile done + return true, nil +} + +// Delete deletes a user +func (s *PostgreSqlUserManager) Delete(ctx context.Context, obj runtime.Object, opts ...resourcemanager.ConfigOption) (bool, error) { + + options := &resourcemanager.Options{} + for _, opt := range opts { + opt(options) + } + + instance, err := s.convert(obj) + if err != nil { + return false, err + } + + adminSecretClient := s.SecretClient + + adminsecretName := instance.Spec.AdminSecret + + if len(instance.Spec.AdminSecret) == 0 { + adminsecretName = instance.Spec.Server + } + key := types.NamespacedName{Name: adminsecretName, Namespace: instance.Namespace} + + var sqlUserSecretClient secrets.SecretClient + if options.SecretClient != nil { + sqlUserSecretClient = options.SecretClient + } else { + sqlUserSecretClient = s.SecretClient + } + + // if the admin secret keyvault is not specified, fall back to global secretclient + if len(instance.Spec.AdminSecretKeyVault) != 0 { + adminSecretClient = keyvaultSecrets.New(instance.Spec.AdminSecretKeyVault) + if len(instance.Spec.AdminSecret) != 0 { + key = types.NamespacedName{Name: instance.Spec.AdminSecret} + } + } + + adminSecret, err := adminSecretClient.Get(ctx, key) + if err != nil { + // assuming if the admin secret is gone the sql server is too + return false, nil + } + + // short circuit connection if database doesn't exist + _, err = s.GetDB(ctx, instance.Spec.ResourceGroup, instance.Spec.Server, instance.Spec.DbName) + if err != nil { + instance.Status.Message = err.Error() + + catch := []string{ + errhelp.ResourceNotFound, + errhelp.ParentNotFoundErrorCode, + errhelp.ResourceGroupNotFoundErrorCode, + } + azerr := errhelp.NewAzureErrorAzureError(err) + if helpers.ContainsString(catch, azerr.Type) { + return false, nil + } + return false, err + } + + user := string(adminSecret["fullyQualifiedUsername"]) + password := string(adminSecret[PSecretPasswordKey]) + fullservername := string(adminSecret["fullyQualifiedServerName"]) + + db, err := s.ConnectToSqlDb(ctx, PDriverName, fullservername, instance.Spec.DbName, PSqlServerPort, user, password) + if err != nil { + instance.Status.Message = errhelp.StripErrorIDs(err) + if strings.Contains(err.Error(), "no pg_hba.conf entry for host") { + // The error indicates the client IP has no access to server. + instance.Status.Message = errhelp.StripErrorIDs(err) + "\nThe IP address is not allowed to access the server. Modify the firewall rule to include the IP address." + //Stop the reconcile and delete the user from service operator side. + return false, nil + } + //stop the reconcile with unkown error + return false, err + } + + requestedusername := instance.Spec.Username + + exists, err := s.UserExists(ctx, db, requestedusername) + if err != nil { + instance.Status.Message = fmt.Sprintf("Delete PostgreSqlUser failed with %s", err.Error()) + return true, err + } + if !exists { + + s.DeleteSecrets(ctx, instance, sqlUserSecretClient) + instance.Status.Message = fmt.Sprintf("The user %s doesn't exist", requestedusername) + //User doesn't exist. Stop the reconcile. + return false, nil + } + + err = s.DropUser(ctx, db, requestedusername) + if err != nil { + instance.Status.Message = fmt.Sprintf("Delete PostgreSqlUser failed with %s", err.Error()) + //stop the reconcile with err + return false, err + } + + // Once the user has been dropped, also delete their secrets. + s.DeleteSecrets(ctx, instance, sqlUserSecretClient) + + instance.Status.Message = fmt.Sprintf("Delete PostgreSqlUser succeeded") + + // no err, no requeue, reconcile will stop + return false, nil +} + +// GetParents gets the parents of the user +func (s *PostgreSqlUserManager) GetParents(obj runtime.Object) ([]resourcemanager.KubeParent, error) { + instance, err := s.convert(obj) + if err != nil { + return nil, err + } + + return []resourcemanager.KubeParent{ + { + Key: types.NamespacedName{ + Namespace: instance.Namespace, + Name: instance.Spec.DbName, + }, + Target: &v1alpha1.PostgreSQLDatabase{}, + }, + { + Key: types.NamespacedName{ + Namespace: instance.Namespace, + Name: instance.Spec.Server, + }, + Target: &v1alpha2.PostgreSQLServer{}, + }, + { + Key: types.NamespacedName{ + Namespace: instance.Namespace, + Name: instance.Spec.ResourceGroup, + }, + Target: &v1alpha1.ResourceGroup{}, + }, + }, nil +} + +// GetStatus gets the status +func (s *PostgreSqlUserManager) GetStatus(obj runtime.Object) (*v1alpha1.ASOStatus, error) { + instance, err := s.convert(obj) + if err != nil { + return nil, err + } + return &instance.Status, nil +} + +func (s *PostgreSqlUserManager) convert(obj runtime.Object) (*v1alpha1.PostgreSQLUser, error) { + local, ok := obj.(*v1alpha1.PostgreSQLUser) + if !ok { + return nil, fmt.Errorf("failed type assertion on kind: %s", obj.GetObjectKind().GroupVersionKind().String()) + } + return local, nil +}