From d68c38721534fd325a330aca9bfbcdc38933ee7c Mon Sep 17 00:00:00 2001 From: David Gubler Date: Tue, 15 Aug 2023 09:11:10 +0200 Subject: [PATCH] wip --- .gitignore | 2 + README.md | 36 ++++++++ gen-dev-env.sh | 20 +++++ go.mod | 4 +- go.sum | 7 ++ main.go | 29 ++++-- pkg/controlApi.go | 24 ----- pkg/grafanaClient.go | 173 ++++++++++++++++++++++++++++++++++++ pkg/reconcile.go | 137 ++++++++-------------------- pkg/reconcileOrg.go | 64 ++++++++----- pkg/reconcileOrgs.go | 77 ++++++++++++++++ pkg/reconcilePermissions.go | 108 ++++++++++++++++++++++ pkg/reconcileUser.go | 62 +++++++++++++ pkg/reconcileUsers.go | 96 ++++++++------------ 14 files changed, 625 insertions(+), 214 deletions(-) create mode 100755 gen-dev-env.sh create mode 100644 pkg/grafanaClient.go create mode 100644 pkg/reconcileOrgs.go create mode 100644 pkg/reconcilePermissions.go create mode 100644 pkg/reconcileUser.go diff --git a/.gitignore b/.gitignore index 463623b..70c0f6d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,4 @@ grafana-organizations-operator env +.idea/ +control.kubeconfig diff --git a/README.md b/README.md index aeee975..b66af67 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,42 @@ Automatically set up Grafana organizations based on information from the [APPUiO Control API](https://kb.vshn.ch/appuio-cloud/references/architecture/control-api.html). +## Design Considerations + +There are a bunch of issues this operator needs to overcome. These issues have a large impact on how the operator is implemented. + +### Systematic Issues + +* Changes can happen both in the APPUiO Control API and on Grafana. Hence it is not sufficient to observe the APPUiO Control API and only sync when its data changes; instead in order to fix misconfigurations in Grafana this operator runs near continuously. + +### Issues with the APPUiO Control API + +* The Control API has no notion of "admin" users, i.e. users which should have access to everything. The configuration option `CONTROL_API_ADMIN_ORG` allows to specify a Control API organization, and all users who have access to that will be treated as admins. +* The `Organization` and `User` types use different GroupVersions. It seems that this cannot be implemented using a single k8s.io client; instead we need two client instances for the Control API. +* The `OrganizationMember` field `spec.userRefs` contains a lot of invalid values, including even values with incorrect syntax. These must be filtered out. + +### Issues with Grafana + +* When a new user is created in Grafana (either by logging in via Keycloak or explicitly created via API), Grafana's `auto_assign_org` "feature" automatically gives the user permission to some organization (whichever is configured). This is almost never what we want. To work around this: + * It would be possible to disable `auto_assign_org`, but then Grafana would create a new organization for every new user, which would be even worse. + * We can't let Grafana create users on demand when they first log ina, thus we create a Grafana users for all known user, even if they don't have permission to do anything, avoiding any situations where Grafana would create a user on demand. + * When we create a Grafana user via API we can specify which organization it should be assigned to. We set this to an organization that the user is allowed to access whenever possible. + * We manage the users of org ID 1, which is the one where users might get assigned to by accident, thereby fixing any wrong permissions which might have happened due to race conditions or other problems. +* Because of the `auto_assign_org` "feature" we have to create the organizations before we can create users. +* Because the `grafana-api-golang-client` implementation is incomplete we are wrapping it in the GrafanaClient type and add some functionality. +* The Grafana API often ignores the OrgID JSON field. The only workaround for this is to set the HTTP header `x-grafana-org-id`. + +## Development Environment Setup + +In order to develop the operator you need: + +* Access to the [APPUiO Control API](https://kb.vshn.ch/appuio-cloud/references/architecture/control-api.html) +* A [Grafana test instance](https://operator-dev-grafana.apps.cloudscale-lpg-2.appuio.cloud/) to write to + +You can run the `gen-dev-env.sh` to set up an environment file (`env`) with the required configuration. + +Once that's done you can source the env file (`. ./env`) and run the operator on your local machine. + ## License [BSD-3-Clause](LICENSE) diff --git a/gen-dev-env.sh b/gen-dev-env.sh new file mode 100755 index 0000000..7e8e95b --- /dev/null +++ b/gen-dev-env.sh @@ -0,0 +1,20 @@ +#!/bin/bash +if [ ! -e control.kubeconfig ]; then + echo "1. Install kubelogin from https://github.com/int128/kubelogin (you must rename the 'kubelogin' binary to 'kubectl-oidc_login' and copy it e.g. to '/usr/local/bin/')" + echo "2. Visit https://portal.appuio.cloud/kubeconfig and put the displayed kubeconfig into control.kubeconfig" + echo "3. Re-run this command" + echo "4. Remove the control.kubeconfig file" + exit 1 +fi +echo -n "" > env +echo "export GRAFANA_URL=https://operator-dev-grafana.apps.cloudscale-lpg-2.appuio.cloud" >> env +echo "export GRAFANA_USERNAME=admin" >> env +echo -n "export GRAFANA_PASSWORD=" >> env +kubectl --as cluster-admin -n vshn-grafana-organizations-operator-dev get secret grafana-env -ojsonpath='{.data.GF_SECURITY_ADMIN_PASSWORD}' | base64 -d >> env +echo "" >> env +echo "export CONTROL_API_URL=https://api.appuio.cloud" >> env +echo -n "export CONTROL_API_TOKEN=" >> env +kubectl --kubeconfig control.kubeconfig get secret grafana-organizations-operator -ojsonpath='{.data.token}' | base64 -d >> env +echo "" >> env +echo "export CONTROL_API_ADMIN_ORG=vshn" >> env +echo "done" diff --git a/go.mod b/go.mod index f6d0639..1139e4c 100644 --- a/go.mod +++ b/go.mod @@ -4,7 +4,8 @@ go 1.20 require ( github.com/appuio/control-api v0.26.0 - github.com/grafana/grafana-api-golang-client v0.21.0 + github.com/grafana/grafana-api-golang-client v0.23.0 + github.com/hashicorp/go-cleanhttp v0.5.2 k8s.io/apimachinery v0.26.2 k8s.io/client-go v0.26.2 k8s.io/klog/v2 v2.90.1 @@ -20,7 +21,6 @@ require ( github.com/golang/protobuf v1.5.3 // indirect github.com/google/gofuzz v1.2.0 // indirect github.com/google/uuid v1.3.0 // indirect - github.com/hashicorp/go-cleanhttp v0.5.2 // indirect github.com/imdario/mergo v0.3.13 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/matttproud/golang_protobuf_extensions v1.0.4 // indirect diff --git a/go.sum b/go.sum index 382c7a7..561f759 100644 --- a/go.sum +++ b/go.sum @@ -18,6 +18,7 @@ github.com/emicklei/go-restful/v3 v3.10.1 h1:rc42Y5YTp7Am7CS630D7JmhRjq4UlEUuEKf github.com/evanphx/json-patch v5.6.0+incompatible h1:jBYDEEiFBPxA0v50tFdvOzQQTCvpL6mnFh5mB2/l16U= github.com/felixge/httpsnoop v1.0.3 h1:s/nj+GCswXYzN5v2DpNMuMQYe+0DDwt5WVCU6CWBdXk= github.com/fsnotify/fsnotify v1.6.0 h1:n+5WquG0fcWoWp6xPWfHdbskMCQaFnG6PfBrh1Ky4HY= +github.com/go-logfmt/logfmt v0.5.1/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs= github.com/go-logr/logr v1.2.0/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.2.3 h1:2DntVwHkVopvECVRSlL5PSo9eG+cAkDCuckLubN+rq0= github.com/go-logr/logr v1.2.3/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= @@ -47,6 +48,8 @@ github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/grafana/grafana-api-golang-client v0.21.0 h1:PQ2Wfo9jMMiftC4VRMlJxbUNvYCXMV1YFDKm7Ny3SaM= github.com/grafana/grafana-api-golang-client v0.21.0/go.mod h1:24W29gPe9yl0/3A9X624TPkAOR8DpHno490cPwnkv8E= +github.com/grafana/grafana-api-golang-client v0.23.0 h1:Uta0dSkxWYf1D83/E7MRLCG69387FiUc+k9U/35nMhY= +github.com/grafana/grafana-api-golang-client v0.23.0/go.mod h1:24W29gPe9yl0/3A9X624TPkAOR8DpHno490cPwnkv8E= github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0 h1:Ovs26xHkKqVztRpIrF/92BcuyuQ/YW4NSIpoGtfXNho= github.com/grpc-ecosystem/grpc-gateway v1.16.0 h1:gmcG1KaJ57LophUzW0Hy8NmPhnMZb4M0+kPpLofRdBo= github.com/grpc-ecosystem/grpc-gateway/v2 v2.15.0 h1:1JYBfzqrWPcCclBwxFCPAou9n+q86mfnu7NAeHfte7A= @@ -56,8 +59,10 @@ github.com/imdario/mergo v0.3.13 h1:lFzP57bqS/wsqKssCGmtLAb8A0wKjLGrve2q3PPVcBk= github.com/imdario/mergo v0.3.13/go.mod h1:4lJ1jqUDcsbIECGy0RUJAXNIhg+6ocWgb1ALK2O4oXg= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= +github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM= github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= @@ -71,9 +76,11 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJ github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= github.com/onsi/ginkgo/v2 v2.6.0 h1:9t9b9vRUbFq3C4qKFCGkVuq/fIHji802N1nrtkh1mNc= github.com/onsi/gomega v1.24.1 h1:KORJXNNTzJXzu4ScJWssJfJMnJ+2QJqhoQSRwNlze9E= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/prometheus/client_golang v1.15.0 h1:5fCgGYogn0hFdhyhLbw7hEsWxufKtY9klyvdNfFlFhM= diff --git a/main.go b/main.go index 88b1c24..495fe6c 100644 --- a/main.go +++ b/main.go @@ -20,29 +20,44 @@ import ( ) var ( - ControlApiToken string - ControlApiUrl string - GrafanaUrl string - GrafanaUsername string - GrafanaPassword string + ControlApiToken string + ControlApiUrl string + ControlApiAdminOrg string + GrafanaUrl string + GrafanaUsername string + GrafanaPassword string + AdminExtraOrgs = [1]int{1} ) func main() { ControlApiUrl = os.Getenv("CONTROL_API_URL") + ControlApiAdminOrg = os.Getenv("CONTROL_API_ADMIN_ORG") ControlApiToken = os.Getenv("CONTROL_API_TOKEN") + ControlApiTokenHidden := "" + if ControlApiToken != "" { + ControlApiTokenHidden = "***hidden***" + } + GrafanaUrl = os.Getenv("GRAFANA_URL") GrafanaUsername = os.Getenv("GRAFANA_USERNAME") if GrafanaUsername == "" { GrafanaUsername = os.Getenv("admin-user") // env variable name used by Grafana Helm chart. And yes using '-' is stupid because of compatibility issues with some shells. } GrafanaPassword = os.Getenv("GRAFANA_PASSWORD") + GrafanaPasswordHidden := "" if GrafanaPassword == "" { GrafanaPassword = os.Getenv("admin-password") // env variable name used by Grafana Helm chart. And yes using '-' is stupid because of compatibility issues with some shells. } + if GrafanaPassword != "" { + GrafanaPasswordHidden = "***hidden***" + } klog.Infof("CONTROL_API_URL: %s\n", ControlApiUrl) + klog.Infof("CONTROL_API_ADMIN_ORG: %s\n", ControlApiAdminOrg) + klog.Infof("CONTROL_API_TOKEN: %s\n", ControlApiTokenHidden) klog.Infof("GRAFANA_URL: %s\n", GrafanaUrl) klog.Infof("GRAFANA_USERNAME: %s\n", GrafanaUsername) + klog.Infof("GRAFANA_PASSWORD: %s\n", GrafanaPasswordHidden) // Because of the strange design of the k8s client we actually need two client objects, which both internally use the same httpClient. // To make this work we also need three (!) config objects, a common one for the httpClient and one for each k8s client. @@ -100,7 +115,7 @@ func main() { json.Unmarshal(db, &dashboard) klog.Info("Starting initial sync...") - err = controller.ReconcileAllOrgs(ctx, organizationAppuioIoClient, appuioIoClient, grafanaConfig, GrafanaUrl, dashboard) + err = controller.Reconcile(ctx, organizationAppuioIoClient, appuioIoClient, ControlApiAdminOrg, grafanaConfig, GrafanaUrl, dashboard) if err != nil { klog.Errorf("Could not do initial reconciliation: %v\n", err) os.Exit(1) @@ -112,7 +127,7 @@ func main() { case <-ctx.Done(): os.Exit(0) } - err = controller.ReconcileAllOrgs(ctx, organizationAppuioIoClient, appuioIoClient, grafanaConfig, GrafanaUrl, dashboard) + err = controller.Reconcile(ctx, organizationAppuioIoClient, appuioIoClient, ControlApiAdminOrg, grafanaConfig, GrafanaUrl, dashboard) if err != nil { klog.Errorf("Could not reconcile (will retry): %v\n", err) } diff --git a/pkg/controlApi.go b/pkg/controlApi.go index d7e37eb..a8132f1 100644 --- a/pkg/controlApi.go +++ b/pkg/controlApi.go @@ -4,9 +4,7 @@ import ( "context" orgs "github.com/appuio/control-api/apis/organization/v1" controlapi "github.com/appuio/control-api/apis/v1" - grafana "github.com/grafana/grafana-api-golang-client" "k8s.io/client-go/rest" - "k8s.io/klog/v2" ) // Generate list of control API organizations @@ -36,25 +34,3 @@ func getControlApiUsersMap(ctx context.Context, appuioIoClient *rest.RESTClient) } return appuioControlApiUsersMap, nil } - -// Generate map containing all Grafana users grouped by organization. Key is the organization ID, value is an array of users. -func getControlApiOrganizationUsersMap(ctx context.Context, grafanaUsersMap map[string]grafana.User, appuioIoClient *rest.RESTClient) (map[string][]grafana.User, error) { - appuioControlApiOrganizationMembers := controlapi.OrganizationMembersList{} - err := appuioIoClient.Get().Resource("OrganizationMembers").Do(ctx).Into(&appuioControlApiOrganizationMembers) - if err != nil { - return nil, err - } - controlApiOrganizationUsersMap := make(map[string][]grafana.User) - for _, memberlist := range appuioControlApiOrganizationMembers.Items { - users := []grafana.User{} - for _, userRef := range memberlist.Spec.UserRefs { - if grafanaUser, ok := grafanaUsersMap[userRef.Name]; ok { - users = append(users, grafanaUser) - } else { - klog.Warningf("Organization '%s' should have user %s but the user wasn't synced to Grafana, ignoring", memberlist.Namespace, userRef.Name) - } - } - controlApiOrganizationUsersMap[memberlist.Namespace] = users - } - return controlApiOrganizationUsersMap, nil -} diff --git a/pkg/grafanaClient.go b/pkg/grafanaClient.go new file mode 100644 index 0000000..d55aed9 --- /dev/null +++ b/pkg/grafanaClient.go @@ -0,0 +1,173 @@ +package controller + +import ( + "encoding/json" + "fmt" + grafana "github.com/grafana/grafana-api-golang-client" + "github.com/hashicorp/go-cleanhttp" + "io" + "net/http" + "net/url" +) + +type UserOrg struct { + OrgID int64 `json:"orgId"` + Name string `json:"name"` + Role string `json:"role"` +} + +type GrafanaClient struct { + config grafana.Config + baseURL url.URL + client *http.Client + grafanaClient *grafana.Client +} + +func NewGrafanaClient(baseURL string, cfg grafana.Config) (*GrafanaClient, error) { + u, err := url.Parse(baseURL) + if err != nil { + return nil, err + } + + if cfg.BasicAuth != nil { + u.User = cfg.BasicAuth + } + + cli := cleanhttp.DefaultClient() + + cfg.Client = cli + grafanaClient, err := grafana.New(baseURL, cfg) + if err != nil { + return nil, err + } + + return &GrafanaClient{ + config: cfg, + baseURL: *u, + client: cli, + grafanaClient: grafanaClient, + }, nil +} + +func (this GrafanaClient) GetUsername() string { + return this.config.BasicAuth.Username() +} + +// This method is missing in the grafana-api-golang-client, that's the reason why we're wrapping that client at all +func (this GrafanaClient) GetUserOrgs(user grafana.User) ([]UserOrg, error) { + url := this.baseURL.String() + fmt.Sprintf("/api/users/%d/orgs", user.ID) + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return nil, err + } + password, _ := this.config.BasicAuth.Password() + req.SetBasicAuth(this.config.BasicAuth.Username(), password) + r, err := this.client.Do(req) + defer r.Body.Close() + if err != nil { + return nil, err + } + + body, err := io.ReadAll(r.Body) + if err != nil { + return nil, err + } + + userOrgs := make([]UserOrg, 0) + err = json.Unmarshal(body, &userOrgs) + if err != nil { + return nil, err + } + return userOrgs, nil +} + +func (this GrafanaClient) CloseIdleConnections() { + this.CloseIdleConnections() +} + +func (this GrafanaClient) OrgUsers(orgID int64) ([]grafana.OrgUser, error) { + return this.grafanaClient.OrgUsers(orgID) +} + +func (this GrafanaClient) UpdateOrgUser(orgID, userID int64, role string) error { + return this.grafanaClient.UpdateOrgUser(orgID, userID, role) +} + +func (this GrafanaClient) AddOrgUser(orgID int64, user, role string) error { + return this.grafanaClient.AddOrgUser(orgID, user, role) +} + +func (this GrafanaClient) RemoveOrgUser(orgID, userID int64) error { + return this.grafanaClient.RemoveOrgUser(orgID, userID) +} + +func (this GrafanaClient) CreateUser(user grafana.User) (int64, error) { + return this.grafanaClient.CreateUser(user) +} + +func (this GrafanaClient) Users() (users []grafana.UserSearch, err error) { + return this.grafanaClient.Users() +} + +func (this GrafanaClient) UserUpdate(u grafana.User) error { + return this.grafanaClient.UserUpdate(u) +} + +func (this GrafanaClient) DeleteUser(id int64) error { + return this.grafanaClient.DeleteUser(id) +} + +func (this GrafanaClient) Orgs() ([]grafana.Org, error) { + return this.grafanaClient.Orgs() +} + +func (this GrafanaClient) UpdateOrg(id int64, name string) error { + return this.grafanaClient.UpdateOrg(id, name) +} + +func (this GrafanaClient) NewOrg(name string) (int64, error) { + return this.grafanaClient.NewOrg(name) +} + +func (this GrafanaClient) Org(id int64) (grafana.Org, error) { + return this.grafanaClient.Org(id) +} + +func (this GrafanaClient) DeleteOrg(id int64) error { + return this.grafanaClient.DeleteOrg(id) +} + +// We don't just wrap this method, we also work around the bad orgID handling of the original library and Grafana API +func (this GrafanaClient) DataSources(org *grafana.Org) ([]*grafana.DataSource, error) { + return this.grafanaClient.WithOrgID(org.ID).DataSources() +} + +// Ditto +func (this GrafanaClient) UpdateDataSource(org *grafana.Org, s *grafana.DataSource) error { + return this.grafanaClient.WithOrgID(org.ID).UpdateDataSource(s) +} + +// Ditto +func (this GrafanaClient) DeleteDataSource(org *grafana.Org, id int64) error { + return this.grafanaClient.WithOrgID(org.ID).DeleteDataSource(id) +} + +// Ditto +func (this GrafanaClient) NewDataSource(org *grafana.Org, s *grafana.DataSource) (int64, error) { + return this.grafanaClient.WithOrgID(org.ID).NewDataSource(s) +} + +// Ditto +func (this GrafanaClient) DataSource(org *grafana.Org, id int64) (*grafana.DataSource, error) { + return this.grafanaClient.WithOrgID(org.ID).DataSource(id) +} + +// Ditto +func (this GrafanaClient) Dashboards(org *grafana.Org) ([]grafana.FolderDashboardSearchResponse, error) { + return this.grafanaClient.WithOrgID(org.ID).Dashboards() +} + +// Ditto +func (this GrafanaClient) NewDashboard(org *grafana.Org, dashboard grafana.Dashboard) (*grafana.DashboardSaveResponse, error) { + return this.grafanaClient.WithOrgID(org.ID).NewDashboard(dashboard) +} diff --git a/pkg/reconcile.go b/pkg/reconcile.go index f928440..07b5140 100644 --- a/pkg/reconcile.go +++ b/pkg/reconcile.go @@ -2,138 +2,71 @@ package controller import ( "context" - orgs "github.com/appuio/control-api/apis/organization/v1" + "errors" + controlapi "github.com/appuio/control-api/apis/v1" grafana "github.com/grafana/grafana-api-golang-client" - "github.com/hashicorp/go-cleanhttp" "k8s.io/client-go/rest" - "k8s.io/klog/v2" - "strings" ) -func ReconcileAllOrgs(ctx context.Context, organizationAppuioIoClient *rest.RESTClient, appuioIoClient *rest.RESTClient, grafanaConfig grafana.Config, grafanaUrl string, dashboard map[string]interface{}) error { - // Fetch everything we need from the control API. - // This is racy because data can change while we fetch it, making the result inconsistent. This may lead to sync errors, - // but they should disappear with subsequent syncs. - controlApiOrganizationsList, err := getControlApiOrganizations(ctx, organizationAppuioIoClient) - if err != nil { - return err - } - controlApiUsersMap, err := getControlApiUsersMap(ctx, appuioIoClient) +var ( + interruptedError = errors.New("interrupted") +) + +func Reconcile(ctx context.Context, organizationAppuioIoClient *rest.RESTClient, appuioIoClient *rest.RESTClient, adminOrg string, grafanaConfig grafana.Config, grafanaUrl string, dashboard map[string]interface{}) error { + grafanaClient, err := NewGrafanaClient(grafanaUrl, grafanaConfig) if err != nil { return err } + defer grafanaClient.CloseIdleConnections() - // Generic Grafana client, not specific to an org (deeper down we'll also create an org-specific Grafana client) - grafanaConfig.Client = cleanhttp.DefaultPooledClient() - grafanaClient, err := grafana.New(grafanaUrl, grafanaConfig) + grafanaOrgsMap, err := reconcileAllOrgs(ctx, organizationAppuioIoClient, grafanaClient, dashboard) if err != nil { return err } - defer grafanaConfig.Client.CloseIdleConnections() - // Get all orgs from Grafana - orgs, err := grafanaClient.Orgs() + uidToGrafanaOrgs, adminUids, err := getControlApiUserOrganizationsMap(ctx, appuioIoClient, grafanaOrgsMap, adminOrg) if err != nil { return err } - // Users are a top-level resource, like organizations. Users can exist even if they don't have permissions to do anything. - grafanaUsersMap, err := reconcileUsers(grafanaClient, controlApiUsersMap) + users, err := getControlApiUsersMap(ctx, appuioIoClient) + err = reconcileUsers(ctx, users, uidToGrafanaOrgs, grafanaClient) if err != nil { return err } - // Lookup table org -> users (editors or viewers) - appuioControlApiOrganizationUsersMap, err := getControlApiOrganizationUsersMap(ctx, grafanaUsersMap, appuioIoClient) + err = reconcilePermissions(ctx, grafanaOrgsMap, uidToGrafanaOrgs, adminUids, grafanaClient) if err != nil { return err } - // List of admin users (for now this is equivalent to all users of the "vshn" org). The same for all orgs. - var desiredAdmins []grafana.User - var ok bool - if desiredAdmins, ok = appuioControlApiOrganizationUsersMap["vshn"]; !ok { - desiredAdmins = []grafana.User{} - } - - // Lookup table org ID (the one from the control API, type string) -> Grafana org - grafanaOrgLookup := make(map[string]grafana.Org) - for _, org := range orgs { - nameComponents := strings.Split(org.Name, " - ") - if len(nameComponents) < 2 || strings.Contains(nameComponents[0], " ") { - continue - } - grafanaOrgLookup[nameComponents[0]] = org - } - - // first make sure that all orgs that need to be present are present - for _, o := range controlApiOrganizationsList { - grafanaOrg, err := reconcileOrgBasic(grafanaOrgLookup, grafanaClient, o) - if err != nil { - return err - } - delete(grafanaOrgLookup, o.Name) - - err = reconcileOrgSettings(grafanaOrg, o.Name, grafanaConfig, grafanaUrl, dashboard, appuioControlApiOrganizationUsersMap[o.Name], desiredAdmins) - if err != nil { - return err - } - - // select with a default case is apparently the only way to do a non-blocking read from a channel - select { - case <-ctx.Done(): - return nil - default: - // carry on - } - } - - // then delete the ones that shouldn't be present - for _, grafanaOrgToBeDeleted := range grafanaOrgLookup { - klog.Infof("Organization %d should not exist, deleting: '%s'", grafanaOrgToBeDeleted.ID, grafanaOrgToBeDeleted.Name) - err = grafanaClient.DeleteOrg(grafanaOrgToBeDeleted.ID) - if err != nil { - return err - } - select { - case <-ctx.Done(): - return nil - default: - } - } - - klog.Infof("Reconcile complete") - return nil } -// Sync the basic org. Uses the generic Grafana client. -func reconcileOrgBasic(grafanaOrgLookup map[string]grafana.Org, grafanaClient *grafana.Client, o orgs.Organization) (*grafana.Org, error) { - displayName := o.Name - if o.Spec.DisplayName != "" { - displayName = o.Spec.DisplayName +// Generate map with all organizations per user. Key is the user ID, value is an array of pointers to Grafana organizations +func getControlApiUserOrganizationsMap(ctx context.Context, appuioIoClient *rest.RESTClient, grafanaOrgsMap map[string]*grafana.Org, adminOrg string) (map[string][]*grafana.Org, []string, error) { + appuioControlApiOrganizationMembers := controlapi.OrganizationMembersList{} + adminUids := make([]string, 0) + err := appuioIoClient.Get().Resource("OrganizationMembers").Do(ctx).Into(&appuioControlApiOrganizationMembers) + if err != nil { + return nil, nil, err } - grafanaOrgDesiredName := o.Name + " - " + displayName - if grafanaOrg, ok := grafanaOrgLookup[o.Name]; ok { - if grafanaOrg.Name != grafanaOrgDesiredName { - klog.Infof("Organization %d has wrong name: '%s', should be '%s'", grafanaOrg.ID, grafanaOrg.Name, grafanaOrgDesiredName) - err := grafanaClient.UpdateOrg(grafanaOrg.ID, grafanaOrgDesiredName) - if err != nil { - return nil, err + userOrganizations := make(map[string][]*grafana.Org) + for _, memberlist := range appuioControlApiOrganizationMembers.Items { + for _, userRef := range memberlist.Spec.UserRefs { + _, ok := userOrganizations[userRef.Name] + if !ok { + userOrganizations[userRef.Name] = make([]*grafana.Org, 0) + } + grafanaOrg, ok := grafanaOrgsMap[memberlist.Namespace] + if ok { + userOrganizations[userRef.Name] = append(userOrganizations[userRef.Name], grafanaOrg) + } + if memberlist.Namespace == adminOrg { + adminUids = append(adminUids, userRef.Name) } } - return &grafanaOrg, nil - } - - klog.Infof("Organization missing, creating: '%s'", grafanaOrgDesiredName) - grafanaOrgId, err := grafanaClient.NewOrg(grafanaOrgDesiredName) - if err != nil { - return nil, err - } - grafanaOrg, err := grafanaClient.Org(grafanaOrgId) - if err != nil { - return nil, err } - return &grafanaOrg, nil + return userOrganizations, adminUids, nil } diff --git a/pkg/reconcileOrg.go b/pkg/reconcileOrg.go index d99fd61..7da5d78 100644 --- a/pkg/reconcileOrg.go +++ b/pkg/reconcileOrg.go @@ -2,32 +2,50 @@ package controller import ( "errors" + orgs "github.com/appuio/control-api/apis/organization/v1" grafana "github.com/grafana/grafana-api-golang-client" - "github.com/hashicorp/go-cleanhttp" "k8s.io/klog/v2" "reflect" "strings" ) -func reconcileOrgSettings(org *grafana.Org, orgName string, config grafana.Config, url string, dashboard map[string]interface{}, desiredUsers []grafana.User, desiredAdmins []grafana.User) error { - // We can't use the grafanaClient from the overarching reconciliation loop because that client doesn't have the X-Grafana-Org-Id header set. - // It appears that the only way to set that header is to create a new client instance. - config.Client = cleanhttp.DefaultPooledClient() - config.OrgID = org.ID - grafanaClient, err := grafana.New(url, config) +// Sync the basic org. Uses the generic Grafana client. +func reconcileOrgBasic(grafanaOrgLookup map[string]grafana.Org, grafanaClient *GrafanaClient, o orgs.Organization) (*grafana.Org, error) { + displayName := o.Name + if o.Spec.DisplayName != "" { + displayName = o.Spec.DisplayName + } + grafanaOrgDesiredName := o.Name + " - " + displayName + + if grafanaOrg, ok := grafanaOrgLookup[o.Name]; ok { + if grafanaOrg.Name != grafanaOrgDesiredName { + klog.Infof("Organization %d has wrong name: '%s', should be '%s'", grafanaOrg.ID, grafanaOrg.Name, grafanaOrgDesiredName) + err := grafanaClient.UpdateOrg(grafanaOrg.ID, grafanaOrgDesiredName) + if err != nil { + return nil, err + } + } + return &grafanaOrg, nil + } + + klog.Infof("Organization missing, creating: '%s'", grafanaOrgDesiredName) + grafanaOrgId, err := grafanaClient.NewOrg(grafanaOrgDesiredName) if err != nil { - return err + return nil, err } - defer config.Client.CloseIdleConnections() - dataSource, err := reconcileOrgDataSource(org, orgName, grafanaClient) + grafanaOrg, err := grafanaClient.Org(grafanaOrgId) if err != nil { - return err + return nil, err } - err = reconcileOrgDashboard(org, dataSource, grafanaClient, dashboard) + return &grafanaOrg, nil +} + +func reconcileOrgSettings(org *grafana.Org, orgName string, grafanaClient *GrafanaClient, dashboard map[string]interface{}) error { + dataSource, err := reconcileOrgDataSource(org, orgName, grafanaClient) if err != nil { return err } - err = reconcileOrgUsers(org, grafanaClient, desiredUsers, desiredAdmins) + err = reconcileOrgDashboard(org, dataSource, grafanaClient, dashboard) if err != nil { return err } @@ -35,7 +53,7 @@ func reconcileOrgSettings(org *grafana.Org, orgName string, config grafana.Confi return nil } -func reconcileOrgDataSource(org *grafana.Org, orgName string, client *grafana.Client) (*grafana.DataSource, error) { +func reconcileOrgDataSource(org *grafana.Org, orgName string, grafanaClient *GrafanaClient) (*grafana.DataSource, error) { // If you add/remove fields here you must also adjust the 'if' statement further down desiredDataSource := &grafana.DataSource{ Name: "Mimir", @@ -56,7 +74,7 @@ func reconcileOrgDataSource(org *grafana.Org, orgName string, client *grafana.Cl var configuredDataSource *grafana.DataSource configuredDataSource = nil - dataSources, err := client.DataSources() + dataSources, err := grafanaClient.DataSources(org) if err != nil { return nil, err } @@ -71,7 +89,7 @@ func reconcileOrgDataSource(org *grafana.Org, orgName string, client *grafana.Cl klog.Infof("Organization %d has misconfigured data source, fixing", org.ID) desiredDataSource.ID = dataSource.ID desiredDataSource.UID = dataSource.UID - err := client.UpdateDataSource(desiredDataSource) + err := grafanaClient.UpdateDataSource(org, desiredDataSource) if err != nil { return nil, err } @@ -81,7 +99,7 @@ func reconcileOrgDataSource(org *grafana.Org, orgName string, client *grafana.Cl } } else { klog.Infof("Organization %d has invalid data source %d %s, removing", org.ID, dataSource.ID, dataSource.Name) - client.DeleteDataSource(dataSource.ID) + grafanaClient.DeleteDataSource(org, dataSource.ID) if err != nil { return nil, err } @@ -90,11 +108,11 @@ func reconcileOrgDataSource(org *grafana.Org, orgName string, client *grafana.Cl } if configuredDataSource == nil { klog.Infof("Organization %d missing data source, creating", org.ID) - dataSourceId, err := client.NewDataSource(desiredDataSource) + dataSourceId, err := grafanaClient.NewDataSource(org, desiredDataSource) if err != nil { return nil, err } - configuredDataSource, err = client.DataSource(dataSourceId) + configuredDataSource, err = grafanaClient.DataSource(org, dataSourceId) if err != nil { return nil, err } @@ -102,13 +120,13 @@ func reconcileOrgDataSource(org *grafana.Org, orgName string, client *grafana.Cl return configuredDataSource, nil } -func reconcileOrgDashboard(org *grafana.Org, dataSource *grafana.DataSource, client *grafana.Client, dashboardModel map[string]interface{}) error { +func reconcileOrgDashboard(org *grafana.Org, dataSource *grafana.DataSource, grafanaClient *GrafanaClient, dashboardModel map[string]interface{}) error { dashboardTitle, ok := dashboardModel["title"] if !ok { errors.New("Invalid dashboard format: 'title' key not found") } - dashboards, err := client.Dashboards() + dashboards, err := grafanaClient.Dashboards(org) if err != nil { return err } @@ -130,13 +148,14 @@ func reconcileOrgDashboard(org *grafana.Org, dataSource *grafana.DataSource, cli Overwrite: true, } klog.Infof("Creating dashboard '%s' for organization %d", dashboardTitle, org.ID) - _, err = client.NewDashboard(dashboard) + _, err = grafanaClient.NewDashboard(org, dashboard) if err != nil { return err } return nil } +// FIXME obsolete? func reconcileOrgUsers(org *grafana.Org, client *grafana.Client, desiredUsers []grafana.User, desiredAdmins []grafana.User) error { orgUsers, err := client.OrgUsersCurrent() if err != nil { @@ -204,6 +223,7 @@ func reconcileOrgUsers(org *grafana.Org, client *grafana.Client, desiredUsers [] return nil } +// FIXME obsolete? func userListContains(userList []grafana.User, user grafana.User) bool { for _, entry := range userList { if entry.ID == user.ID && entry.Login == user.Login { diff --git a/pkg/reconcileOrgs.go b/pkg/reconcileOrgs.go new file mode 100644 index 0000000..c003593 --- /dev/null +++ b/pkg/reconcileOrgs.go @@ -0,0 +1,77 @@ +package controller + +import ( + "context" + grafana "github.com/grafana/grafana-api-golang-client" + "k8s.io/client-go/rest" + "k8s.io/klog/v2" + "strings" +) + +func reconcileAllOrgs(ctx context.Context, organizationAppuioIoClient *rest.RESTClient, grafanaClient *GrafanaClient, dashboard map[string]interface{}) (map[string]*grafana.Org, error) { + grafanaOrgLookupFinal := make(map[string]*grafana.Org) + + // Fetch everything we need from the control API. + // This is racy because data can change while we fetch it, making the result inconsistent. This may lead to sync errors, + // but they should disappear with subsequent syncs. + controlApiOrganizationsList, err := getControlApiOrganizations(ctx, organizationAppuioIoClient) + if err != nil { + return nil, err + } + + // Get all orgs from Grafana + orgs, err := grafanaClient.Orgs() + if err != nil { + return nil, err + } + + // Lookup table org ID (the one from the control API, type string) -> Grafana org + grafanaOrgLookup := make(map[string]grafana.Org) + for _, org := range orgs { + nameComponents := strings.Split(org.Name, " - ") + if len(nameComponents) < 2 || strings.Contains(nameComponents[0], " ") { + continue + } + grafanaOrgLookup[nameComponents[0]] = org + } + + // first make sure that all orgs that need to be present are present + for _, o := range controlApiOrganizationsList { + grafanaOrg, err := reconcileOrgBasic(grafanaOrgLookup, grafanaClient, o) + if err != nil { + return nil, err + } + delete(grafanaOrgLookup, o.Name) + + err = reconcileOrgSettings(grafanaOrg, o.Name, grafanaClient, dashboard) + if err != nil { + return nil, err + } + + grafanaOrgLookupFinal[o.Name] = grafanaOrg + + // select with a default case is apparently the only way to do a non-blocking read from a channel + select { + case <-ctx.Done(): + return nil, interruptedError + default: + // carry on + } + } + + // then delete the ones that shouldn't be present + for _, grafanaOrgToBeDeleted := range grafanaOrgLookup { + klog.Infof("Organization %d should not exist, deleting: '%s'", grafanaOrgToBeDeleted.ID, grafanaOrgToBeDeleted.Name) + err = grafanaClient.DeleteOrg(grafanaOrgToBeDeleted.ID) + if err != nil { + return nil, err + } + select { + case <-ctx.Done(): + return nil, interruptedError + default: + } + } + + return grafanaOrgLookupFinal, nil +} diff --git a/pkg/reconcilePermissions.go b/pkg/reconcilePermissions.go new file mode 100644 index 0000000..48040bc --- /dev/null +++ b/pkg/reconcilePermissions.go @@ -0,0 +1,108 @@ +package controller + +import ( + "context" + grafana "github.com/grafana/grafana-api-golang-client" + "k8s.io/klog/v2" + "k8s.io/utils/strings/slices" +) + +func reconcilePermissions(ctx context.Context, grafanaOrgsMap map[string]*grafana.Org, uidToGrafanaOrgs map[string][]*grafana.Org, adminUids []string, grafanaClient *GrafanaClient) error { + // We have to iterate over grafanaOrgs here, hence we need to invert our uid <-> grafanaOrg association table + // FIXME: Shouldn't be necessary anymore with GrafanaClient + grafanaOrgToUids := make(map[*grafana.Org][]string) + for uid, orgs := range uidToGrafanaOrgs { + for _, org := range orgs { + uids, ok := grafanaOrgToUids[org] + if !ok { + grafanaOrgToUids[org] = make([]string, 0) + } + grafanaOrgToUids[org] = append(uids, uid) + } + } + + for _, org := range grafanaOrgsMap { + initialOrgUsers, err := grafanaClient.OrgUsers(org.ID) + if err != nil { + return err + } + initialOrgUsersMap := make(map[string]grafana.OrgUser) + for _, initialOrgUser := range initialOrgUsers { + if initialOrgUser.Login != "admin" { // ignore admin + initialOrgUsersMap[initialOrgUser.Login] = initialOrgUser + } + } + + desiredOrgUsers, ok := grafanaOrgToUids[org] + if !ok { + desiredOrgUsers = make([]string, 0) + } + + // Check and if necessary add desired users + for _, desiredOrgUser := range desiredOrgUsers { + if initialOrgUser, ok := initialOrgUsersMap[desiredOrgUser]; ok { + // Permission exists + if initialOrgUser.Role != "Viewer" && initialOrgUser.Role != "Editor" && !slices.Contains(adminUids, initialOrgUser.Login) { + // A normal user can only have roles "Viewer" and "Editor". Other roles would give the user too many permissions, e.g. to change the data source which would be a security issue. + err := grafanaClient.UpdateOrgUser(org.ID, initialOrgUser.UserID, "Editor") + if err != nil { + // This can happen due to race conditions, hence just a warning + klog.Warning(err) + } + } + } else { + // Permission missing + klog.Infof("User '%s' should have access to org '%s' (%d), adding", desiredOrgUser, org.Name, org.ID) + err := grafanaClient.AddOrgUser(org.ID, desiredOrgUser, "Editor") + if err != nil { + // This can happen due to race conditions, hence just a warning + klog.Warning(err) + } + } + delete(initialOrgUsersMap, desiredOrgUser) + + select { + case <-ctx.Done(): + return interruptedError + default: + } + } + + // Check and if necessary add desired admins + for _, desiredOrgAdmin := range adminUids { + if _, ok := initialOrgUsersMap[desiredOrgAdmin]; !ok { + klog.Infof("User '%s' should be admin of org '%s' (%d), adding", desiredOrgAdmin, org.Name, org.ID) + err := grafanaClient.AddOrgUser(org.ID, desiredOrgAdmin, "Admin") + if err != nil { + // This can happen due to race conditions, hence just a warning + klog.Warning(err) + } + } + delete(initialOrgUsersMap, desiredOrgAdmin) + + select { + case <-ctx.Done(): + return interruptedError + default: + } + } + + // Remove all the users that are left in initialOrgUsersMap + for _, undesiredOrgUser := range initialOrgUsersMap { + klog.Infof("User '%s' (%d) must not have access to org '%s' (%d), removing", undesiredOrgUser, undesiredOrgUser.UserID, org.Name, org.ID) + err := grafanaClient.RemoveOrgUser(org.ID, undesiredOrgUser.UserID) + if err != nil { + // This can happen due to race conditions, hence just a warning + klog.Warning(err) + } + + select { + case <-ctx.Done(): + return interruptedError + default: + } + } + } + + return nil +} diff --git a/pkg/reconcileUser.go b/pkg/reconcileUser.go new file mode 100644 index 0000000..1405cb5 --- /dev/null +++ b/pkg/reconcileUser.go @@ -0,0 +1,62 @@ +package controller + +import ( + "crypto/rand" + controlapi "github.com/appuio/control-api/apis/v1" + grafana "github.com/grafana/grafana-api-golang-client" + "math/big" +) + +func generatePassword() (string, error) { + const voc string = "abcdfghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" + len := big.NewInt(int64(len(voc))) + pw := "" + + for i := 0; i < 32; i++ { + index, err := rand.Int(rand.Reader, len) + if err != nil { + return "", err + } + pw = pw + string(voc[index.Uint64()]) + } + return pw, nil +} + +func createUser(client *GrafanaClient, user controlapi.User, orgs []*grafana.Org) (*grafana.User, error) { + password, err := generatePassword() + if err != nil { + return nil, err + } + grafanaUser := grafana.User{ + Email: user.Status.Email, + Login: user.Name, + Name: user.Status.DisplayName, + Password: password, + } + if len(orgs) > 0 { + // By default Grafana gives the user permission to org ID 1, which is likely not correct. + // This isn't a huge problem because we'll strip the permission in the next step, but still we'd like to avoid + // this race condition and thus we tell Grafana to use another organization, if available. + grafanaUser.OrgID = orgs[0].ID + } + grafanaUser.ID, err = client.CreateUser(grafanaUser) + if err != nil { + return nil, err + } + + userOrgs, err := client.GetUserOrgs(grafanaUser) + if err != nil { + return nil, err + } + + for _, userOrg := range userOrgs { + // we immediately remove the user from the automatically assigned org because who knows what permissions the user got on that org (can't be controlled when creating the user) + // yes this is stupid but that's how Grafana works + err = client.RemoveOrgUser(userOrg.OrgID, grafanaUser.ID) + if err != nil { + return nil, err + } + } + + return &grafanaUser, nil +} diff --git a/pkg/reconcileUsers.go b/pkg/reconcileUsers.go index 37c6ecc..96f3864 100644 --- a/pkg/reconcileUsers.go +++ b/pkg/reconcileUsers.go @@ -1,95 +1,77 @@ package controller import ( - "crypto/rand" + "context" controlapi "github.com/appuio/control-api/apis/v1" grafana "github.com/grafana/grafana-api-golang-client" "k8s.io/klog/v2" - "math/big" ) -func generatePassword() (string, error) { - const voc string = "abcdfghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" - len := big.NewInt(int64(len(voc))) - pw := "" - - for i := 0; i < 32; i++ { - index, err := rand.Int(rand.Reader, len) - if err != nil { - return "", err - } - pw = pw + string(voc[index.Uint64()]) - } - return pw, nil -} - -func createUser(client *grafana.Client, user controlapi.User) (*grafana.User, error) { - password, err := generatePassword() - if err != nil { - return nil, err - } - grafanaUser := grafana.User{ - Email: user.Status.Email, - Login: user.Name, - Name: user.Status.DisplayName, - Password: password, - } - grafanaUser.ID, err = client.CreateUser(grafanaUser) - if err != nil { - return nil, err - } - return &grafanaUser, nil -} - -func reconcileUsers(client *grafana.Client, users map[string]controlapi.User) (map[string]grafana.User, error) { - grafanaUsers, err := client.Users() +func reconcileUsers(ctx context.Context, users map[string]controlapi.User, uidToGrafanaOrgs map[string][]*grafana.Org, grafanaClient *GrafanaClient) error { + grafanaUsers, err := grafanaClient.Users() if err != nil { - return nil, err + return err } - grafanaUsersSet := make(map[string]grafana.UserSearch) + grafanaUsersMap := make(map[string]grafana.UserSearch) for _, grafanaUser := range grafanaUsers { - grafanaUsersSet[grafanaUser.Login] = grafanaUser + if grafanaUser.Login != "admin" && grafanaUser.Login != grafanaClient.GetUsername() { // ignore admin + grafanaUsersMap[grafanaUser.Login] = grafanaUser + } } - finalGrafanaUsersMap := make(map[string]grafana.User) - for _, user := range users { - if grafanaUserSearch, ok := grafanaUsersSet[user.Name]; ok { + orgs, ok := uidToGrafanaOrgs[user.Name] + if !ok { + // Even if the user doesn't have access to any org we still need to create it. + // This is to work around Grafana's auto_assign_org "feature": If the user were to log in via + // Keycloak Grafana would create a user and give it permission to see org ID 1. + // We can prevent this by pre-emptively creating the user and stripping it of all permissions. + orgs = make([]*grafana.Org, 0) + } + + var grafanaUser *grafana.User + if grafanaUserSearch, ok := grafanaUsersMap[user.Name]; ok { if grafanaUserSearch.Email != user.Status.Email || grafanaUserSearch.IsAdmin || grafanaUserSearch.Login != user.Name || grafanaUserSearch.Name != user.Status.DisplayName { klog.Infof("User '%s' differs, fixing", user.Name) - grafanaUser := grafana.User{ + grafanaUser = &grafana.User{ ID: grafanaUserSearch.ID, IsAdmin: false, Login: user.Name, Name: user.Status.DisplayName, } - client.UserUpdate(grafanaUser) + grafanaClient.UserUpdate(*grafanaUser) } - finalGrafanaUsersMap[grafanaUserSearch.Login] = grafana.User{ID: grafanaUserSearch.ID, Login: grafanaUserSearch.Login} } else { klog.Infof("User '%s' is missing, adding", user.Name) - grafanaUser, err := createUser(client, user) + grafanaUser, err = createUser(grafanaClient, user, orgs) if err != nil { - //return err // for now just continue in case errors happen klog.Error(err) continue } - klog.Infof("%d", grafanaUser.ID) - finalGrafanaUsersMap[grafanaUser.Login] = grafana.User{ID: grafanaUser.ID, Login: grafanaUser.Login} } - klog.Infof("User '%s' OK", user.Name) - delete(grafanaUsersSet, user.Name) + delete(grafanaUsersMap, user.Name) + + select { + case <-ctx.Done(): + return interruptedError + default: + } } - delete(grafanaUsersSet, "admin") // don't delete the admin user... + for _, grafanaUser := range grafanaUsersMap { + klog.Infof("User '%s' (%d) is not in APPUiO Control API or does not have access to any organizations, removing", grafanaUser.Login, grafanaUser.ID) + grafanaClient.DeleteUser(grafanaUser.ID) - for _, grafanaUser := range grafanaUsersSet { - klog.Infof("User '%s' (%d) is not in APPUiO Control API, removing", grafanaUser.Login, grafanaUser.ID) - client.DeleteUser(grafanaUser.ID) + select { + case <-ctx.Done(): + return interruptedError + default: + } } - return finalGrafanaUsersMap, nil + + return nil }