From b7001379ce3bda41b24e2df480b13c3614d03842 Mon Sep 17 00:00:00 2001 From: Andreas Bergmeier Date: Thu, 22 Jun 2023 13:08:19 +0200 Subject: [PATCH] Add new type Users --- Dockerfile | 1 + PROJECT | 11 +- api/v1/organization_types.go | 1 - api/v1/users_types.go | 52 +++ api/v1/zz_generated.deepcopy.go | 114 ++++++ cmd/main.go | 8 + .../grafana.abergmeier.github.io_users.yaml | 125 +++++++ config/rbac/role.yaml | 26 ++ .../controller/organization_controller.go | 84 ++--- internal/controller/users_controller.go | 349 ++++++++++++++++++ internal/request.go | 39 ++ 11 files changed, 753 insertions(+), 57 deletions(-) create mode 100644 api/v1/users_types.go create mode 100644 config/crd/bases/grafana.abergmeier.github.io_users.yaml create mode 100644 internal/controller/users_controller.go create mode 100644 internal/request.go diff --git a/Dockerfile b/Dockerfile index ef4cfaf..f9029a8 100644 --- a/Dockerfile +++ b/Dockerfile @@ -15,6 +15,7 @@ RUN go mod download COPY cmd/main.go cmd/main.go COPY api/ api/ COPY internal/controller/ internal/controller/ +COPY internal/ internal/ # Build # the GOARCH has not a default value to allow the binary be built according to the host where the command diff --git a/PROJECT b/PROJECT index 62277a3..faa6c56 100644 --- a/PROJECT +++ b/PROJECT @@ -12,9 +12,18 @@ resources: crdVersion: v1 namespaced: true controller: true - domain: my.domain + domain: grafana.abergmeier.github.io group: grafana kind: Organization path: github.com/abergmeier/grafana-org/api/v1 version: v1 +- api: + crdVersion: v1 + namespaced: true + controller: true + domain: grafana.abergmeier.github.io + group: grafana + kind: Users + path: github.com/abergmeier/grafana-org/api/v1 + version: v1 version: "3" diff --git a/api/v1/organization_types.go b/api/v1/organization_types.go index bc76bc3..e42520d 100644 --- a/api/v1/organization_types.go +++ b/api/v1/organization_types.go @@ -26,7 +26,6 @@ import ( // OrganizationSpec defines the desired state of Organization type OrganizationSpec struct { - // INSERT ADDITIONAL SPEC FIELDS - desired state of cluster // Important: Run "make" to regenerate code after modifying this file Url string `json:"url"` diff --git a/api/v1/users_types.go b/api/v1/users_types.go new file mode 100644 index 0000000..1cbede0 --- /dev/null +++ b/api/v1/users_types.go @@ -0,0 +1,52 @@ +package v1 + +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. + +// UserSpec defines the desired state of User +type UserSpec struct { + // Important: Run "make" to regenerate code after modifying this file + + Url string `json:"url"` + Admin *GrafanaAdmin `json:"admin,omitempty"` + Users []User `json:"users,omitempty"` +} + +type User struct { + Name string `json:"name,omitempty"` + Login string `json:"login"` + Email string `json:"email,omitempty"` +} + +// UserStatus defines the observed state of User +type UserStatus struct { + // INSERT ADDITIONAL STATUS FIELD - define observed state of cluster + // Important: Run "make" to regenerate code after modifying this file +} + +//+kubebuilder:object:root=true +//+kubebuilder:subresource:status + +// Users is the Schema for the users API +type Users struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + Spec UserSpec `json:"spec,omitempty"` + Status UserStatus `json:"status,omitempty"` +} + +//+kubebuilder:object:root=true + +// UsersList contains a list of User +type UsersList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []Users `json:"items"` +} + +func init() { + SchemeBuilder.Register(&Users{}, &UsersList{}) +} diff --git a/api/v1/zz_generated.deepcopy.go b/api/v1/zz_generated.deepcopy.go index 3a8ea61..504ab03 100644 --- a/api/v1/zz_generated.deepcopy.go +++ b/api/v1/zz_generated.deepcopy.go @@ -177,6 +177,61 @@ func (in *PasswordSpec) DeepCopy() *PasswordSpec { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *User) DeepCopyInto(out *User) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new User. +func (in *User) DeepCopy() *User { + if in == nil { + return nil + } + out := new(User) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *UserSpec) DeepCopyInto(out *UserSpec) { + *out = *in + if in.Admin != nil { + in, out := &in.Admin, &out.Admin + *out = new(GrafanaAdmin) + (*in).DeepCopyInto(*out) + } + if in.Users != nil { + in, out := &in.Users, &out.Users + *out = make([]User, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new UserSpec. +func (in *UserSpec) DeepCopy() *UserSpec { + if in == nil { + return nil + } + out := new(UserSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *UserStatus) DeepCopyInto(out *UserStatus) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new UserStatus. +func (in *UserStatus) DeepCopy() *UserStatus { + if in == nil { + return nil + } + out := new(UserStatus) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *UsernameSpec) DeepCopyInto(out *UsernameSpec) { *out = *in @@ -197,6 +252,65 @@ func (in *UsernameSpec) DeepCopy() *UsernameSpec { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *Users) DeepCopyInto(out *Users) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + in.Spec.DeepCopyInto(&out.Spec) + out.Status = in.Status +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Users. +func (in *Users) DeepCopy() *Users { + if in == nil { + return nil + } + out := new(Users) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *Users) 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 *UsersList) DeepCopyInto(out *UsersList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]Users, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new UsersList. +func (in *UsersList) DeepCopy() *UsersList { + if in == nil { + return nil + } + out := new(UsersList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *UsersList) 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 *ValueSource) DeepCopyInto(out *ValueSource) { *out = *in diff --git a/cmd/main.go b/cmd/main.go index f82d736..4555f7d 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -105,6 +105,14 @@ func main() { setupLog.Error(err, "unable to create controller", "controller", "Organization") os.Exit(1) } + + if err = (&controller.UsersReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + }).SetupWithManager(mgr); err != nil { + setupLog.Error(err, "unable to create controller", "controller", "Users") + os.Exit(1) + } //+kubebuilder:scaffold:builder if err := mgr.AddHealthzCheck("healthz", healthz.Ping); err != nil { diff --git a/config/crd/bases/grafana.abergmeier.github.io_users.yaml b/config/crd/bases/grafana.abergmeier.github.io_users.yaml new file mode 100644 index 0000000..130d30c --- /dev/null +++ b/config/crd/bases/grafana.abergmeier.github.io_users.yaml @@ -0,0 +1,125 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.11.3 + creationTimestamp: null + name: users.grafana.abergmeier.github.io +spec: + group: grafana.abergmeier.github.io + names: + kind: Users + listKind: UsersList + plural: users + singular: users + scope: Namespaced + versions: + - name: v1 + schema: + openAPIV3Schema: + description: Users is the Schema for the users API + properties: + apiVersion: + description: 'APIVersion defines the versioned schema of this representation + of an object. Servers should convert recognized schemas to the latest + internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources' + type: string + kind: + description: 'Kind is a string value representing the REST resource this + object represents. Servers may infer this from the endpoint the client + submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds' + type: string + metadata: + type: object + spec: + description: UserSpec defines the desired state of User + properties: + admin: + properties: + password: + properties: + valueFrom: + description: 'Optional: Specifies a source the value should + come from.' + properties: + secretKeyRef: + description: SecretKeySelector selects a key of a Secret. + properties: + key: + description: The key of the secret to select from. Must + be a valid secret key. + type: string + name: + description: 'Name of the referent. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + TODO: Add other useful fields. apiVersion, kind, + uid?' + type: string + optional: + description: Specify whether the Secret or its key + must be defined + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + type: object + type: object + username: + properties: + valueFrom: + description: 'Optional: Specifies a source the value should + come from.' + properties: + secretKeyRef: + description: SecretKeySelector selects a key of a Secret. + properties: + key: + description: The key of the secret to select from. Must + be a valid secret key. + type: string + name: + description: 'Name of the referent. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + TODO: Add other useful fields. apiVersion, kind, + uid?' + type: string + optional: + description: Specify whether the Secret or its key + must be defined + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + type: object + type: object + required: + - password + - username + type: object + url: + type: string + users: + items: + properties: + email: + type: string + login: + type: string + name: + type: string + required: + - login + type: object + type: array + required: + - url + type: object + status: + description: UserStatus defines the observed state of User + type: object + type: object + served: true + storage: true + subresources: + status: {} diff --git a/config/rbac/role.yaml b/config/rbac/role.yaml index 84969bc..aad56d3 100644 --- a/config/rbac/role.yaml +++ b/config/rbac/role.yaml @@ -31,3 +31,29 @@ rules: - get - patch - update +- apiGroups: + - grafana.abergmeier.github.io + resources: + - users + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - grafana.abergmeier.github.io + resources: + - users/finalizers + verbs: + - update +- apiGroups: + - grafana.abergmeier.github.io + resources: + - users/status + verbs: + - get + - patch + - update diff --git a/internal/controller/organization_controller.go b/internal/controller/organization_controller.go index d9c5977..6b1c41f 100644 --- a/internal/controller/organization_controller.go +++ b/internal/controller/organization_controller.go @@ -19,19 +19,17 @@ package controller import ( "context" "fmt" - "net/url" "sync" "time" "k8s.io/apimachinery/pkg/runtime" - "k8s.io/apimachinery/pkg/types" ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/log" grafanav1 "github.com/abergmeier/grafana-org/api/v1" + "github.com/abergmeier/grafana-org/internal" gapi "github.com/grafana/grafana-api-golang-client" - corev1 "k8s.io/api/core/v1" ) // OrganizationReconciler reconciles a Organization object @@ -46,8 +44,7 @@ type userConfig struct { } type missingUserConfig struct { userConfig - email email - OrgID int64 + gapi.User } type changeUserConfig struct { @@ -83,7 +80,7 @@ func (r *OrganizationReconciler) Reconcile(ctx context.Context, req ctrl.Request func (r *OrganizationReconciler) reconcileGrafanaOrganization(ctx context.Context, req ctrl.Request, org *grafanav1.Organization) error { - ui, err := r.buildUserInfo(ctx, req, org) + ui, err := internal.BuildRequestUserInfo(ctx, r.Client, req.Namespace, org.Spec.Admin) if err != nil { return fmt.Errorf("extracting user credentials failed: %w", err) } @@ -95,12 +92,12 @@ func (r *OrganizationReconciler) reconcileGrafanaOrganization(ctx context.Contex return fmt.Errorf("creating Grafana Client failed: %w", err) } - desired, err := r.findDesired(ctx, org) + desiredState, err := r.buildDesiredState(ctx, org) if err != nil { return fmt.Errorf("finding desired state failed: %w", err) } - userActions, err := calculateUserBuckets(client, desired) + userActions, err := calculateOrgUserBuckets(client, desiredState) if err != nil { return fmt.Errorf("calculating diff failed: %w", err) } @@ -122,41 +119,12 @@ func (r *OrganizationReconciler) reconcileGrafanaOrganization(ctx context.Contex return nil } -func (r *OrganizationReconciler) buildUserInfo(ctx context.Context, req ctrl.Request, org *grafanav1.Organization) (*url.Userinfo, error) { - secret := &corev1.Secret{} - - name := types.NamespacedName{ - Namespace: req.Namespace, - Name: org.Spec.Admin.Username.ValueFrom.SecretKeyRef.Name, - } - err := r.Client.Get(ctx, name, secret) - if err != nil { - return nil, fmt.Errorf("getting secret `%s` failed: %w", org.Spec.Admin.Username.ValueFrom.SecretKeyRef.Name, err) - } - - username := secret.Data[org.Spec.Admin.Username.ValueFrom.SecretKeyRef.Key] +func (r *OrganizationReconciler) buildDesiredState(ctx context.Context, org *grafanav1.Organization) (map[email]grafanav1.OrganizationUser, error) { - name = types.NamespacedName{ - Namespace: req.Namespace, - Name: org.Spec.Admin.Password.ValueFrom.SecretKeyRef.Name, - } - err = r.Client.Get(ctx, name, secret) - if err != nil { - return nil, fmt.Errorf("getting secret `%s` failed: %w", org.Spec.Admin.Password.ValueFrom.SecretKeyRef.Name, err) - } - password := secret.Data[org.Spec.Admin.Password.ValueFrom.SecretKeyRef.Key] - - return url.UserPassword(string(username), string(password)), nil -} - -func (r *OrganizationReconciler) findDesired(ctx context.Context, org *grafanav1.Organization) (map[email]userConfig, error) { - - expected := map[email]userConfig{} + expected := map[email]grafanav1.OrganizationUser{} for _, u := range org.Spec.Users { - expected[email(u.Email)] = userConfig{ - role: u.Role, - } + expected[email(u.Email)] = u } return expected, nil @@ -189,25 +157,21 @@ func (r *OrganizationReconciler) addMissingUsers(ctx context.Context, client *ga go func(i int, uc *missingUserConfig) { defer wg.Done() - _, err := client.CreateUser(gapi.User{ - Email: string(uc.email), - Name: string(uc.email), - Login: string(uc.email), - }) + _, err := client.CreateUser(uc.User) if err != nil { errs[i] = &orgUserAddError{ error: err, - Email: uc.email, + Email: email(uc.Email), OrgID: uc.OrgID, } return } - err = client.AddOrgUser(uc.OrgID, string(uc.email), uc.role) + err = client.AddOrgUser(uc.OrgID, string(uc.Email), uc.role) if err != nil { errs[i] = &orgUserAddError{ error: err, - Email: uc.email, + Email: email(uc.Email), OrgID: uc.OrgID, } return @@ -317,7 +281,7 @@ type orgUserBuckets struct { obsolete map[email]gapi.OrgUser } -func calculateUserBuckets(client *gapi.Client, expected map[email]userConfig) (*orgUserBuckets, error) { +func calculateOrgUserBuckets(client *gapi.Client, expected map[email]grafanav1.OrganizationUser) (*orgUserBuckets, error) { currentOrgUsers, err := client.OrgUsersCurrent() if err != nil { return nil, err @@ -338,22 +302,32 @@ func calculateUserBuckets(client *gapi.Client, expected map[email]userConfig) (* alreadyPresentConfig, ok := currentOrgUserMap[email] if !ok { users.missing = append(users.missing, missingUserConfig{ - email: email, - userConfig: uc, - OrgID: 1, + User: gapi.User{ + Email: string(email), + OrgID: 1, + }, + userConfig: userConfig{ + role: uc.Role, + }, }) continue } - if uc.role != alreadyPresentConfig.Role { + if uc.Role != alreadyPresentConfig.Role { users.change = append(users.change, changeUserConfig{ - userConfig: uc, + userConfig: userConfig{ + role: uc.Role, + }, + current: gapi.OrgUser{ + OrgID: 1, + Email: uc.Email, + }, }) } } for _, uc := range users.missing { - delete(users.obsolete, uc.email) + delete(users.obsolete, email(uc.Email)) } for _, uc := range users.change { diff --git a/internal/controller/users_controller.go b/internal/controller/users_controller.go new file mode 100644 index 0000000..2a90083 --- /dev/null +++ b/internal/controller/users_controller.go @@ -0,0 +1,349 @@ +/* +Copyright 2023. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package controller + +import ( + "context" + "fmt" + "math/rand" + "sync" + "time" + + "k8s.io/apimachinery/pkg/runtime" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/log" + + grafanav1 "github.com/abergmeier/grafana-org/api/v1" + "github.com/abergmeier/grafana-org/internal" + gapi "github.com/grafana/grafana-api-golang-client" +) + +const letterBytes = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ" + +// UsersReconciler reconciles a Users object +type UsersReconciler struct { + client.Client + Scheme *runtime.Scheme +} + +//+kubebuilder:rbac:groups=grafana.abergmeier.github.io,resources=users,verbs=get;list;watch;create;update;patch;delete +//+kubebuilder:rbac:groups=grafana.abergmeier.github.io,resources=users/status,verbs=get;update;patch +//+kubebuilder:rbac:groups=grafana.abergmeier.github.io,resources=users/finalizers,verbs=update + +// Reconcile is part of the main kubernetes reconciliation loop which aims to +// move the current state of the cluster closer to the desired state. +func (r *UsersReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { + + org := &grafanav1.Users{} + err := r.Client.Get(ctx, req.NamespacedName, org) + if err != nil { + return ctrl.Result{ + RequeueAfter: time.Second, + }, err + } + + err = r.reconcileGrafanaUsers(ctx, req, org) + if err != nil { + return ctrl.Result{ + RequeueAfter: time.Second, + }, err + } + + return ctrl.Result{}, nil +} + +func (r *UsersReconciler) reconcileGrafanaUsers(ctx context.Context, req ctrl.Request, users *grafanav1.Users) error { + + ui, err := internal.BuildRequestUserInfo(ctx, r.Client, req.Namespace, users.Spec.Admin) + if err != nil { + return fmt.Errorf("extracting user credentials failed: %w", err) + } + + client, err := gapi.New(users.Spec.Url, gapi.Config{ + BasicAuth: ui, + }) + if err != nil { + return fmt.Errorf("creating Grafana Client failed: %w", err) + } + + desiredState, err := r.buildDesiredState(ctx, users) + if err != nil { + return fmt.Errorf("finding desired state failed: %w", err) + } + + userActions, err := calculateUserBuckets(client, desiredState) + if err != nil { + return fmt.Errorf("calculating diff failed: %w", err) + } + + err = r.createMissingUsers(ctx, client, userActions.missing) + if err != nil { + return fmt.Errorf("add missing users failed: %w", err) + } + err = r.updateCurrentUsers(ctx, client, userActions.change) + if err != nil { + return fmt.Errorf("changing present users failed: %w", err) + } + + err = r.deleteObsoleteUsers(ctx, client, userActions.obsoleteIds) + if err != nil { + return fmt.Errorf("remove obsolete users failed: %w", err) + } + + return nil +} + +func (r *UsersReconciler) buildDesiredState(ctx context.Context, users *grafanav1.Users) (map[email]grafanav1.User, error) { + + expected := map[email]grafanav1.User{} + + for _, u := range users.Spec.Users { + expected[email(u.Email)] = u + } + + return expected, nil +} + +// SetupWithManager sets up the controller with the Manager. +func (r *UsersReconciler) SetupWithManager(mgr ctrl.Manager) error { + return ctrl.NewControllerManagedBy(mgr). + For(&grafanav1.Users{}). + Complete(r) +} + +type createUserError struct { + error + Email email +} + +func (err *createUserError) Error() string { + return fmt.Sprintf("adding User (email: %s) failed: %s", err.Email, err.error) +} + +func (r *UsersReconciler) createMissingUsers(ctx context.Context, client *gapi.Client, users []gapi.User) error { + logger := log.FromContext(ctx) + + wg := sync.WaitGroup{} + wg.Add(len(users)) + errs := make([]*createUserError, len(users)) + for i, u := range users { + go func(i int, u *gapi.User) { + defer wg.Done() + + // We need to have a password since HTTP API is enforcing it + if u.Password == "" { + u.Password = randStringBytes(16) + } + + _, err := client.CreateUser(*u) + if err != nil { + errs[i] = &createUserError{ + error: err, + Email: email(u.Email), + } + return + } + }(i, &u) + } + wg.Wait() + unreturned := filterNilCreateUserErrors(errs) + if len(unreturned) >= 1 { + for _, err := range unreturned[1:] { + logger.Error(err.error, "adding User failed", "email", err.Email) + } + return unreturned[0] + } + return nil +} + +type updateUserError struct { + error + Email string + UserID int64 +} + +func (r *UsersReconciler) updateCurrentUsers(ctx context.Context, client *gapi.Client, users []gapi.User) error { + logger := log.FromContext(ctx) + + wg := sync.WaitGroup{} + wg.Add(len(users)) + errs := make([]*updateUserError, len(users)) + for i, u := range users { + go func(i int, u *gapi.User) { + defer wg.Done() + err := client.UserUpdate(*u) + if err != nil { + errs[i] = &updateUserError{ + error: err, + Email: u.Email, + UserID: u.ID, + } + } + }(i, &u) + } + wg.Wait() + unreturned := filterNilUpdateUserErrors(errs) + if len(unreturned) >= 1 { + for _, err := range unreturned[1:] { + logger.Error(err.error, "changing User failed", "userid", err.UserID, "email", err.Email) + } + return fmt.Errorf("changing User in Organization failed: %w", unreturned[0]) + } + return nil +} + +type deleteUserError struct { + error + UserID int64 +} + +func (r *UsersReconciler) deleteObsoleteUsers(ctx context.Context, client *gapi.Client, userIds []int64) error { + logger := log.FromContext(ctx) + + wg := sync.WaitGroup{} + wg.Add(len(userIds)) + errs := make([]*deleteUserError, len(userIds)) + for i, uid := range userIds { + go func(i int, uid int64) { + defer wg.Done() + err := client.DeleteUser(uid) + if err != nil { + errs[i] = &deleteUserError{ + error: err, + UserID: uid, + } + return + } + }(i, uid) + } + wg.Wait() + unreturned := filterNilDeleteUserErrors(errs) + if len(unreturned) >= 1 { + for _, err := range unreturned[1:] { + logger.Error(err.error, "deleting User failed", "userid", err.UserID) + } + return fmt.Errorf("deleting User failed: %w", unreturned[0]) + } + return nil +} + +type userBuckets struct { + missing []gapi.User + change []gapi.User + obsoleteIds []int64 +} + +func calculateUserBuckets(client *gapi.Client, desired map[email]grafanav1.User) (*userBuckets, error) { + currentUsers, err := client.Users() + if err != nil { + return nil, err + } + + users := &userBuckets{} + + currentUserMap := map[email]gapi.User{} + + obsoleteIds := make(map[int64]struct{}, len(currentUsers)) + + for _, ou := range currentUsers { + currentUserMap[email(ou.Email)] = gapi.User{ + ID: ou.ID, + Email: ou.Email, + Name: ou.Name, + Login: ou.Login, + } + obsoleteIds[ou.ID] = struct{}{} + } + + for email, uc := range desired { + alreadyPresentConfig, ok := currentUserMap[email] + if !ok { + users.missing = append(users.missing, gapi.User{ + Email: string(email), + Name: uc.Name, + Login: uc.Login, + }) + continue + } + + if needsChange(&uc, &alreadyPresentConfig) { + users.change = append(users.change, gapi.User{ + Email: uc.Email, + Name: uc.Name, + Login: uc.Login, + }) + continue + } + } + + for _, uc := range users.missing { + delete(obsoleteIds, uc.ID) + } + + for _, uc := range users.change { + delete(obsoleteIds, uc.ID) + } + + for id := range obsoleteIds { + users.obsoleteIds = append(users.obsoleteIds, id) + } + + return users, nil +} + +func needsChange(lhs *grafanav1.User, current *gapi.User) bool { + return lhs.Email != current.Email || lhs.Login != current.Login || lhs.Name != current.Name +} + +func filterNilCreateUserErrors(errs []*createUserError) []*createUserError { + filtered := make([]*createUserError, 0, len(errs)) + for _, err := range errs { + if err != nil { + filtered = append(filtered, err) + } + } + return filtered +} + +func filterNilUpdateUserErrors(errs []*updateUserError) []*updateUserError { + filtered := make([]*updateUserError, 0, len(errs)) + for _, err := range errs { + if err != nil { + filtered = append(filtered, err) + } + } + return filtered +} + +func filterNilDeleteUserErrors(errs []*deleteUserError) []*deleteUserError { + filtered := make([]*deleteUserError, 0, len(errs)) + for _, err := range errs { + if err != nil { + filtered = append(filtered, err) + } + } + return filtered +} + +func randStringBytes(n int) string { + b := make([]byte, n) + for i := range b { + b[i] = letterBytes[rand.Intn(len(letterBytes))] + } + return string(b) +} diff --git a/internal/request.go b/internal/request.go new file mode 100644 index 0000000..7574a18 --- /dev/null +++ b/internal/request.go @@ -0,0 +1,39 @@ +package internal + +import ( + "context" + "fmt" + "net/url" + + grafanav1 "github.com/abergmeier/grafana-org/api/v1" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +func BuildRequestUserInfo(ctx context.Context, client client.Client, namespace string, admin *grafanav1.GrafanaAdmin) (*url.Userinfo, error) { + secret := &corev1.Secret{} + + name := types.NamespacedName{ + Namespace: namespace, + Name: admin.Username.ValueFrom.SecretKeyRef.Name, + } + err := client.Get(ctx, name, secret) + if err != nil { + return nil, fmt.Errorf("getting secret `%s` failed: %w", admin.Username.ValueFrom.SecretKeyRef.Name, err) + } + + username := secret.Data[admin.Username.ValueFrom.SecretKeyRef.Key] + + name = types.NamespacedName{ + Namespace: namespace, + Name: admin.Password.ValueFrom.SecretKeyRef.Name, + } + err = client.Get(ctx, name, secret) + if err != nil { + return nil, fmt.Errorf("getting secret `%s` failed: %w", admin.Password.ValueFrom.SecretKeyRef.Name, err) + } + password := secret.Data[admin.Password.ValueFrom.SecretKeyRef.Key] + + return url.UserPassword(string(username), string(password)), nil +}