From cc95754e0da38d48f1011169b1fe53b991003476 Mon Sep 17 00:00:00 2001 From: Marcus Efraimsson Date: Mon, 22 Jun 2020 17:49:13 +0200 Subject: [PATCH] Provisioning: Adds support for enabling app plugins (#25649) Adds support for enabling app plugins using provisioning. Ref #11409 Co-authored-by: Arve Knudsen Co-authored-by: Diana Payton <52059945+oddlittlebird@users.noreply.github.com> --- conf/provisioning/plugins/sample.yaml | 11 ++ docs/sources/administration/provisioning.md | 34 ++++- docs/sources/http_api/admin.md | 2 + packaging/deb/control/postinst | 5 + packaging/rpm/control/postinst | 5 + pkg/api/admin_provisioning.go | 9 ++ pkg/api/api.go | 1 + pkg/plugins/queries.go | 6 + .../provisioning/plugins/config_reader.go | 134 ++++++++++++++++++ .../plugins/config_reader_test.go | 86 +++++++++++ .../plugins/plugin_provisioner.go | 84 +++++++++++ .../plugins/plugin_provisioner_test.go | 95 +++++++++++++ .../test-configs/broken-yaml/broken.yaml | 4 + .../test-configs/broken-yaml/not.yaml.text | 6 + .../correct-properties.yaml | 10 ++ .../test-configs/empty_folder/.gitignore | 4 + .../incorrect-settings.yaml | 3 + .../test-configs/unknown-app/unknown-app.yaml | 2 + pkg/services/provisioning/plugins/types.go | 57 ++++++++ pkg/services/provisioning/provisioning.go | 20 ++- .../provisioning/provisioning_mock.go | 10 ++ .../provisioning/provisioning_test.go | 1 + 22 files changed, 585 insertions(+), 4 deletions(-) create mode 100644 conf/provisioning/plugins/sample.yaml create mode 100644 pkg/services/provisioning/plugins/config_reader.go create mode 100644 pkg/services/provisioning/plugins/config_reader_test.go create mode 100644 pkg/services/provisioning/plugins/plugin_provisioner.go create mode 100644 pkg/services/provisioning/plugins/plugin_provisioner_test.go create mode 100644 pkg/services/provisioning/plugins/testdata/test-configs/broken-yaml/broken.yaml create mode 100644 pkg/services/provisioning/plugins/testdata/test-configs/broken-yaml/not.yaml.text create mode 100644 pkg/services/provisioning/plugins/testdata/test-configs/correct-properties/correct-properties.yaml create mode 100644 pkg/services/provisioning/plugins/testdata/test-configs/empty_folder/.gitignore create mode 100644 pkg/services/provisioning/plugins/testdata/test-configs/incorrect-settings/incorrect-settings.yaml create mode 100644 pkg/services/provisioning/plugins/testdata/test-configs/unknown-app/unknown-app.yaml create mode 100644 pkg/services/provisioning/plugins/types.go diff --git a/conf/provisioning/plugins/sample.yaml b/conf/provisioning/plugins/sample.yaml new file mode 100644 index 00000000000..9dccf59f6bb --- /dev/null +++ b/conf/provisioning/plugins/sample.yaml @@ -0,0 +1,11 @@ +# # config file version +apiVersion: 1 + +# apps: +# - type: grafana-example-app +# org_name: Main Org. +# disabled: true +# - type: raintank-worldping-app +# org_id: 1 +# jsonData: +# apiKey: "API KEY" diff --git a/docs/sources/administration/provisioning.md b/docs/sources/administration/provisioning.md index 9496f63728b..3fbe0b8aae0 100755 --- a/docs/sources/administration/provisioning.md +++ b/docs/sources/administration/provisioning.md @@ -65,7 +65,7 @@ Currently we do not provide any scripts/manifests for configuring Grafana. Rathe > This feature is available from v5.0 -It's possible to manage datasources in Grafana by adding one or more yaml config files in the [`provisioning/datasources`](/installation/configuration/#provisioning) directory. Each config file can contain a list of `datasources` that will be added or updated during start up. If the datasource already exists, Grafana will update it to match the configuration file. The config file can also contain a list of datasources that should be deleted. That list is called `deleteDatasources`. Grafana will delete datasources listed in `deleteDatasources` before inserting/updating those in the `datasource` list. +You can manage data sources in Grafana by adding one or more YAML config files in the [`provisioning/datasources`]({{< relref "../installation/configuration/#provisioning" >}}) directory. Each config file can contain a list of `datasources` that will be added or updated during start up. If the data source already exists, then Grafana updates it to match the configuration file. The config file can also contain a list of data sources that should be deleted. That list is called `deleteDatasources`. Grafana deletes data sources listed in `deleteDatasources` before inserting or updating those in the `datasource` list. ### Running Multiple Grafana Instances @@ -208,9 +208,39 @@ datasources: httpHeaderValue2: 'Bearer XXXXXXXXX' ``` +## Plugins + +> This feature is available from v7.1 + +You can manage plugins in Grafana by adding one or more YAML config files in the [`provisioning/plugins`]({{< relref "../installation/configuration/#provisioning" >}}) directory. Each config file can contain a list of `apps` that will be updated during start up. Grafana updates each app to match the configuration file. + +### Example plugin configuration file + +```yaml +apiVersion: 1 + +apps: + # the type of app, plugin identifier. Required + - type: raintank-worldping-app + # Org ID. Default to 1, unless org_name is specified + org_id: 1 + # Org name. Overrides org_id unless org_id not specified + org_name: Main Org. + # disable the app. Default to false. + disabled: false + # fields that will be converted to json and stored in jsonData. Custom per app. + jsonData: + # key/value pairs of string to object + key: value + # fields that will be converted to json, encrypted and stored in secureJsonData. Custom per app. + secureJsonData: + # key/value pairs of string to string + key: value +``` + ## Dashboards -It's possible to manage dashboards in Grafana by adding one or more yaml config files in the [`provisioning/dashboards`]({{< relref "../installation/configuration.md" >}}) directory. Each config file can contain a list of `dashboards providers` that will load dashboards into Grafana from the local filesystem. +You can manage dashboards in Grafana by adding one or more YAML config files in the [`provisioning/dashboards`]({{< relref "../installation/configuration.md" >}}) directory. Each config file can contain a list of `dashboards providers` that load dashboards into Grafana from the local filesystem. The dashboard provider config file looks somewhat like this: diff --git a/docs/sources/http_api/admin.md b/docs/sources/http_api/admin.md index 77d56f66cf7..2087272574f 100644 --- a/docs/sources/http_api/admin.md +++ b/docs/sources/http_api/admin.md @@ -465,6 +465,8 @@ Content-Type: application/json `POST /api/admin/provisioning/datasources/reload` +`POST /api/admin/provisioning/plugins/reload` + `POST /api/admin/provisioning/notifications/reload` Reloads the provisioning config files for specified type and provision entities again. It won't return diff --git a/packaging/deb/control/postinst b/packaging/deb/control/postinst index 957f8aef307..b2246886c0c 100755 --- a/packaging/deb/control/postinst +++ b/packaging/deb/control/postinst @@ -42,6 +42,11 @@ case "$1" in cp /usr/share/grafana/conf/provisioning/notifiers/sample.yaml $PROVISIONING_CFG_DIR/notifiers/sample.yaml fi + if [ ! -d $PROVISIONING_CFG_DIR/plugins ]; then + mkdir -p $PROVISIONING_CFG_DIR/plugins + cp /usr/share/grafana/conf/provisioning/plugins/sample.yaml $PROVISIONING_CFG_DIR/plugins/sample.yaml + fi + # configuration files should not be modifiable by grafana user, as this can be a security issue chown -Rh root:$GRAFANA_GROUP /etc/grafana/* chmod 755 /etc/grafana diff --git a/packaging/rpm/control/postinst b/packaging/rpm/control/postinst index cd679838487..f591f4e15e4 100755 --- a/packaging/rpm/control/postinst +++ b/packaging/rpm/control/postinst @@ -56,6 +56,11 @@ if [ $1 -eq 1 ] ; then cp /usr/share/grafana/conf/provisioning/notifiers/sample.yaml $PROVISIONING_CFG_DIR/notifiers/sample.yaml fi + if [ ! -d $PROVISIONING_CFG_DIR/plugins ]; then + mkdir -p $PROVISIONING_CFG_DIR/plugins + cp /usr/share/grafana/conf/provisioning/plugins/sample.yaml $PROVISIONING_CFG_DIR/plugins/sample.yaml + fi + # Set user permissions on /var/log/grafana, /var/lib/grafana mkdir -p /var/log/grafana /var/lib/grafana chown -R $GRAFANA_USER:$GRAFANA_GROUP /var/log/grafana /var/lib/grafana diff --git a/pkg/api/admin_provisioning.go b/pkg/api/admin_provisioning.go index 13ffeda8cef..9a845c4654d 100644 --- a/pkg/api/admin_provisioning.go +++ b/pkg/api/admin_provisioning.go @@ -2,6 +2,7 @@ package api import ( "context" + "github.com/grafana/grafana/pkg/models" ) @@ -21,6 +22,14 @@ func (server *HTTPServer) AdminProvisioningReloadDatasources(c *models.ReqContex return Success("Datasources config reloaded") } +func (server *HTTPServer) AdminProvisioningReloadPlugins(c *models.ReqContext) Response { + err := server.ProvisioningService.ProvisionPlugins() + if err != nil { + return Error(500, "Failed to reload plugins config", err) + } + return Success("Plugins config reloaded") +} + func (server *HTTPServer) AdminProvisioningReloadNotifications(c *models.ReqContext) Response { err := server.ProvisioningService.ProvisionNotifications() if err != nil { diff --git a/pkg/api/api.go b/pkg/api/api.go index 9c2f20e0085..44de1db8bdf 100644 --- a/pkg/api/api.go +++ b/pkg/api/api.go @@ -405,6 +405,7 @@ func (hs *HTTPServer) registerRoutes() { adminRoute.Post("/users/:id/revoke-auth-token", bind(models.RevokeAuthTokenCmd{}), Wrap(hs.AdminRevokeUserAuthToken)) adminRoute.Post("/provisioning/dashboards/reload", Wrap(hs.AdminProvisioningReloadDashboards)) + adminRoute.Post("/provisioning/plugins/reload", Wrap(hs.AdminProvisioningReloadPlugins)) adminRoute.Post("/provisioning/datasources/reload", Wrap(hs.AdminProvisioningReloadDatasources)) adminRoute.Post("/provisioning/notifications/reload", Wrap(hs.AdminProvisioningReloadNotifications)) adminRoute.Post("/ldap/reload", Wrap(hs.ReloadLDAPCfg)) diff --git a/pkg/plugins/queries.go b/pkg/plugins/queries.go index 43c8d7f4e74..9dc2305fe3e 100644 --- a/pkg/plugins/queries.go +++ b/pkg/plugins/queries.go @@ -85,3 +85,9 @@ func GetEnabledPlugins(orgId int64) (*EnabledPlugins, error) { return &enabledPlugins, nil } + +// IsAppInstalled checks if an app plugin with provided plugin ID is installed. +func IsAppInstalled(pluginID string) bool { + _, exists := Apps[pluginID] + return exists +} diff --git a/pkg/services/provisioning/plugins/config_reader.go b/pkg/services/provisioning/plugins/config_reader.go new file mode 100644 index 00000000000..ec95c40cf78 --- /dev/null +++ b/pkg/services/provisioning/plugins/config_reader.go @@ -0,0 +1,134 @@ +package plugins + +import ( + "fmt" + "io/ioutil" + "os" + "path/filepath" + "strings" + + "github.com/grafana/grafana/pkg/infra/log" + "github.com/grafana/grafana/pkg/plugins" + "gopkg.in/yaml.v2" +) + +type configReader interface { + readConfig(path string) ([]*pluginsAsConfig, error) +} + +type configReaderImpl struct { + log log.Logger +} + +func newConfigReader(logger log.Logger) configReader { + return &configReaderImpl{log: logger} +} + +func (cr *configReaderImpl) readConfig(path string) ([]*pluginsAsConfig, error) { + var apps []*pluginsAsConfig + cr.log.Debug("Looking for plugin provisioning files", "path", path) + + files, err := ioutil.ReadDir(path) + if err != nil { + cr.log.Error("Failed to read plugin provisioning files from directory", "path", path, "error", err) + return apps, nil + } + + for _, file := range files { + if strings.HasSuffix(file.Name(), ".yaml") || strings.HasSuffix(file.Name(), ".yml") { + cr.log.Debug("Parsing plugin provisioning file", "path", path, "file.Name", file.Name()) + app, err := cr.parsePluginConfig(path, file) + if err != nil { + return nil, err + } + + if app != nil { + apps = append(apps, app) + } + } + } + + cr.log.Debug("Validating plugins") + if err := validateRequiredField(apps); err != nil { + return nil, err + } + + checkOrgIDAndOrgName(apps) + + err = validatePluginsConfig(apps) + if err != nil { + return nil, err + } + + return apps, nil +} + +func (cr *configReaderImpl) parsePluginConfig(path string, file os.FileInfo) (*pluginsAsConfig, error) { + filename, err := filepath.Abs(filepath.Join(path, file.Name())) + if err != nil { + return nil, err + } + + yamlFile, err := ioutil.ReadFile(filename) + if err != nil { + return nil, err + } + + var cfg *pluginsAsConfigV0 + err = yaml.Unmarshal(yamlFile, &cfg) + if err != nil { + return nil, err + } + + return cfg.mapToPluginsFromConfig(), nil +} + +func validateRequiredField(apps []*pluginsAsConfig) error { + for i := range apps { + var errStrings []string + for index, app := range apps[i].Apps { + if app.PluginID == "" { + errStrings = append( + errStrings, + fmt.Sprintf("app item %d in configuration doesn't contain required field type", index+1), + ) + } + } + + if len(errStrings) != 0 { + return fmt.Errorf(strings.Join(errStrings, "\n")) + } + } + + return nil +} + +func validatePluginsConfig(apps []*pluginsAsConfig) error { + for i := range apps { + if apps[i].Apps == nil { + continue + } + + for _, app := range apps[i].Apps { + if !plugins.IsAppInstalled(app.PluginID) { + return fmt.Errorf("app plugin not installed: %s", app.PluginID) + } + } + } + + return nil +} + +func checkOrgIDAndOrgName(apps []*pluginsAsConfig) { + for i := range apps { + for _, app := range apps[i].Apps { + if app.OrgID < 1 { + if app.OrgName == "" { + app.OrgID = 1 + } else { + app.OrgID = 0 + } + } + } + } +} diff --git a/pkg/services/provisioning/plugins/config_reader_test.go b/pkg/services/provisioning/plugins/config_reader_test.go new file mode 100644 index 00000000000..992e815b25f --- /dev/null +++ b/pkg/services/provisioning/plugins/config_reader_test.go @@ -0,0 +1,86 @@ +package plugins + +import ( + "os" + "testing" + + "github.com/grafana/grafana/pkg/infra/log" + "github.com/grafana/grafana/pkg/plugins" + "github.com/stretchr/testify/require" +) + +var ( + incorrectSettings = "./testdata/test-configs/incorrect-settings" + brokenYaml = "./testdata/test-configs/broken-yaml" + emptyFolder = "./testdata/test-configs/empty_folder" + unknownApp = "./testdata/test-configs/unknown-app" + correctProperties = "./testdata/test-configs/correct-properties" +) + +func TestConfigReader(t *testing.T) { + t.Run("Broken yaml should return error", func(t *testing.T) { + reader := newConfigReader(log.New("test logger")) + _, err := reader.readConfig(brokenYaml) + require.Error(t, err) + }) + + t.Run("Skip invalid directory", func(t *testing.T) { + cfgProvider := newConfigReader(log.New("test logger")) + cfg, err := cfgProvider.readConfig(emptyFolder) + require.NoError(t, err) + require.Len(t, cfg, 0) + }) + + t.Run("Unknown app plugin should return error", func(t *testing.T) { + cfgProvider := newConfigReader(log.New("test logger")) + _, err := cfgProvider.readConfig(unknownApp) + require.Error(t, err) + require.Equal(t, "app plugin not installed: nonexisting", err.Error()) + }) + + t.Run("Read incorrect properties", func(t *testing.T) { + cfgProvider := newConfigReader(log.New("test logger")) + _, err := cfgProvider.readConfig(incorrectSettings) + require.Error(t, err) + require.Equal(t, "app item 1 in configuration doesn't contain required field type", err.Error()) + }) + + t.Run("Can read correct properties", func(t *testing.T) { + plugins.Apps = map[string]*plugins.AppPlugin{ + "test-plugin": {}, + "test-plugin-2": {}, + } + + err := os.Setenv("ENABLE_PLUGIN_VAR", "test-plugin") + require.NoError(t, err) + t.Cleanup(func() { + _ = os.Unsetenv("ENABLE_PLUGIN_VAR") + }) + + cfgProvider := newConfigReader(log.New("test logger")) + cfg, err := cfgProvider.readConfig(correctProperties) + require.NoError(t, err) + require.Len(t, cfg, 1) + + testCases := []struct { + ExpectedPluginID string + ExpectedOrgID int64 + ExpectedOrgName string + ExpectedEnabled bool + }{ + {ExpectedPluginID: "test-plugin", ExpectedOrgID: 2, ExpectedOrgName: "", ExpectedEnabled: true}, + {ExpectedPluginID: "test-plugin-2", ExpectedOrgID: 3, ExpectedOrgName: "", ExpectedEnabled: false}, + {ExpectedPluginID: "test-plugin", ExpectedOrgID: 0, ExpectedOrgName: "Org 3", ExpectedEnabled: true}, + {ExpectedPluginID: "test-plugin-2", ExpectedOrgID: 1, ExpectedOrgName: "", ExpectedEnabled: true}, + } + + for index, tc := range testCases { + app := cfg[0].Apps[index] + require.NotNil(t, app) + require.Equal(t, tc.ExpectedPluginID, app.PluginID) + require.Equal(t, tc.ExpectedOrgID, app.OrgID) + require.Equal(t, tc.ExpectedOrgName, app.OrgName) + require.Equal(t, tc.ExpectedEnabled, app.Enabled) + } + }) +} diff --git a/pkg/services/provisioning/plugins/plugin_provisioner.go b/pkg/services/provisioning/plugins/plugin_provisioner.go new file mode 100644 index 00000000000..0056db4a6b7 --- /dev/null +++ b/pkg/services/provisioning/plugins/plugin_provisioner.go @@ -0,0 +1,84 @@ +package plugins + +import ( + "github.com/grafana/grafana/pkg/bus" + "github.com/grafana/grafana/pkg/infra/log" + "github.com/grafana/grafana/pkg/models" +) + +// Provision scans a directory for provisioning config files +// and provisions the app in those files. +func Provision(configDirectory string) error { + ap := newAppProvisioner(log.New("provisioning.plugins")) + return ap.applyChanges(configDirectory) +} + +// PluginProvisioner is responsible for provisioning apps based on +// configuration read by the `configReader` +type PluginProvisioner struct { + log log.Logger + cfgProvider configReader +} + +func newAppProvisioner(log log.Logger) PluginProvisioner { + return PluginProvisioner{ + log: log, + cfgProvider: newConfigReader(log), + } +} + +func (ap *PluginProvisioner) apply(cfg *pluginsAsConfig) error { + for _, app := range cfg.Apps { + if app.OrgID == 0 && app.OrgName != "" { + getOrgQuery := &models.GetOrgByNameQuery{Name: app.OrgName} + if err := bus.Dispatch(getOrgQuery); err != nil { + return err + } + app.OrgID = getOrgQuery.Result.Id + } else if app.OrgID < 0 { + app.OrgID = 1 + } + + query := &models.GetPluginSettingByIdQuery{OrgId: app.OrgID, PluginId: app.PluginID} + err := bus.Dispatch(query) + if err != nil { + if err != models.ErrPluginSettingNotFound { + return err + } + } else { + app.PluginVersion = query.Result.PluginVersion + app.Pinned = query.Result.Pinned + } + + ap.log.Info("Updating app from configuration ", "type", app.PluginID, "enabled", app.Enabled) + cmd := &models.UpdatePluginSettingCmd{ + OrgId: app.OrgID, + PluginId: app.PluginID, + Enabled: app.Enabled, + Pinned: app.Pinned, + JsonData: app.JSONData, + SecureJsonData: app.SecureJSONData, + PluginVersion: app.PluginVersion, + } + if err := bus.Dispatch(cmd); err != nil { + return err + } + } + + return nil +} + +func (ap *PluginProvisioner) applyChanges(configPath string) error { + configs, err := ap.cfgProvider.readConfig(configPath) + if err != nil { + return err + } + + for _, cfg := range configs { + if err := ap.apply(cfg); err != nil { + return err + } + } + + return nil +} diff --git a/pkg/services/provisioning/plugins/plugin_provisioner_test.go b/pkg/services/provisioning/plugins/plugin_provisioner_test.go new file mode 100644 index 00000000000..f8d157e0b39 --- /dev/null +++ b/pkg/services/provisioning/plugins/plugin_provisioner_test.go @@ -0,0 +1,95 @@ +package plugins + +import ( + "errors" + "testing" + + "github.com/grafana/grafana/pkg/bus" + "github.com/grafana/grafana/pkg/infra/log" + "github.com/grafana/grafana/pkg/models" + "github.com/stretchr/testify/require" +) + +func TestPluginProvisioner(t *testing.T) { + t.Run("Should return error when config reader returns error", func(t *testing.T) { + expectedErr := errors.New("test") + reader := &testConfigReader{err: expectedErr} + ap := PluginProvisioner{log: log.New("test"), cfgProvider: reader} + err := ap.applyChanges("") + require.Equal(t, expectedErr, err) + }) + + t.Run("Should apply configurations", func(t *testing.T) { + bus.AddHandler("test", func(query *models.GetOrgByNameQuery) error { + if query.Name == "Org 4" { + query.Result = &models.Org{Id: 4} + } + + return nil + }) + + bus.AddHandler("test", func(query *models.GetPluginSettingByIdQuery) error { + if query.PluginId == "test-plugin" && query.OrgId == 2 { + query.Result = &models.PluginSetting{ + PluginVersion: "2.0.1", + } + return nil + } + + return models.ErrPluginSettingNotFound + }) + + sentCommands := []*models.UpdatePluginSettingCmd{} + + bus.AddHandler("test", func(cmd *models.UpdatePluginSettingCmd) error { + sentCommands = append(sentCommands, cmd) + return nil + }) + + cfg := []*pluginsAsConfig{ + { + Apps: []*appFromConfig{ + {PluginID: "test-plugin", OrgID: 2, Enabled: true}, + {PluginID: "test-plugin-2", OrgID: 3, Enabled: false}, + {PluginID: "test-plugin", OrgName: "Org 4", Enabled: true}, + {PluginID: "test-plugin-2", OrgID: 1, Enabled: true}, + }, + }, + } + reader := &testConfigReader{result: cfg} + ap := PluginProvisioner{log: log.New("test"), cfgProvider: reader} + err := ap.applyChanges("") + require.NoError(t, err) + require.Len(t, sentCommands, 4) + + testCases := []struct { + ExpectedPluginID string + ExpectedOrgID int64 + ExpectedEnabled bool + ExpectedPluginVersion string + }{ + {ExpectedPluginID: "test-plugin", ExpectedOrgID: 2, ExpectedEnabled: true, ExpectedPluginVersion: "2.0.1"}, + {ExpectedPluginID: "test-plugin-2", ExpectedOrgID: 3, ExpectedEnabled: false}, + {ExpectedPluginID: "test-plugin", ExpectedOrgID: 4, ExpectedEnabled: true}, + {ExpectedPluginID: "test-plugin-2", ExpectedOrgID: 1, ExpectedEnabled: true}, + } + + for index, tc := range testCases { + cmd := sentCommands[index] + require.NotNil(t, cmd) + require.Equal(t, tc.ExpectedPluginID, cmd.PluginId) + require.Equal(t, tc.ExpectedOrgID, cmd.OrgId) + require.Equal(t, tc.ExpectedEnabled, cmd.Enabled) + require.Equal(t, tc.ExpectedPluginVersion, cmd.PluginVersion) + } + }) +} + +type testConfigReader struct { + result []*pluginsAsConfig + err error +} + +func (tcr *testConfigReader) readConfig(path string) ([]*pluginsAsConfig, error) { + return tcr.result, tcr.err +} diff --git a/pkg/services/provisioning/plugins/testdata/test-configs/broken-yaml/broken.yaml b/pkg/services/provisioning/plugins/testdata/test-configs/broken-yaml/broken.yaml new file mode 100644 index 00000000000..0de5462ba84 --- /dev/null +++ b/pkg/services/provisioning/plugins/testdata/test-configs/broken-yaml/broken.yaml @@ -0,0 +1,4 @@ +apps: + - type: something + org_id: 2 + disabled: false diff --git a/pkg/services/provisioning/plugins/testdata/test-configs/broken-yaml/not.yaml.text b/pkg/services/provisioning/plugins/testdata/test-configs/broken-yaml/not.yaml.text new file mode 100644 index 00000000000..9050f543cef --- /dev/null +++ b/pkg/services/provisioning/plugins/testdata/test-configs/broken-yaml/not.yaml.text @@ -0,0 +1,6 @@ +#sfxzgnsxzcvnbzcvn +cvbn +cvbn +c +vbn +cvbncvbn \ No newline at end of file diff --git a/pkg/services/provisioning/plugins/testdata/test-configs/correct-properties/correct-properties.yaml b/pkg/services/provisioning/plugins/testdata/test-configs/correct-properties/correct-properties.yaml new file mode 100644 index 00000000000..2b293eb6547 --- /dev/null +++ b/pkg/services/provisioning/plugins/testdata/test-configs/correct-properties/correct-properties.yaml @@ -0,0 +1,10 @@ +apps: + - type: $ENABLE_PLUGIN_VAR + org_id: 2 + disabled: false + - type: test-plugin-2 + org_id: 3 + disabled: true + - type: test-plugin + org_name: Org 3 + - type: test-plugin-2 diff --git a/pkg/services/provisioning/plugins/testdata/test-configs/empty_folder/.gitignore b/pkg/services/provisioning/plugins/testdata/test-configs/empty_folder/.gitignore new file mode 100644 index 00000000000..86d0cb2726c --- /dev/null +++ b/pkg/services/provisioning/plugins/testdata/test-configs/empty_folder/.gitignore @@ -0,0 +1,4 @@ +# Ignore everything in this directory +* +# Except this file +!.gitignore \ No newline at end of file diff --git a/pkg/services/provisioning/plugins/testdata/test-configs/incorrect-settings/incorrect-settings.yaml b/pkg/services/provisioning/plugins/testdata/test-configs/incorrect-settings/incorrect-settings.yaml new file mode 100644 index 00000000000..cecb79cebff --- /dev/null +++ b/pkg/services/provisioning/plugins/testdata/test-configs/incorrect-settings/incorrect-settings.yaml @@ -0,0 +1,3 @@ +apps: + - org_id: 2 + disabled: false diff --git a/pkg/services/provisioning/plugins/testdata/test-configs/unknown-app/unknown-app.yaml b/pkg/services/provisioning/plugins/testdata/test-configs/unknown-app/unknown-app.yaml new file mode 100644 index 00000000000..5977bb2ae22 --- /dev/null +++ b/pkg/services/provisioning/plugins/testdata/test-configs/unknown-app/unknown-app.yaml @@ -0,0 +1,2 @@ +apps: + - type: nonexisting diff --git a/pkg/services/provisioning/plugins/types.go b/pkg/services/provisioning/plugins/types.go new file mode 100644 index 00000000000..2d2d13c64ff --- /dev/null +++ b/pkg/services/provisioning/plugins/types.go @@ -0,0 +1,57 @@ +package plugins + +import "github.com/grafana/grafana/pkg/services/provisioning/values" + +// pluginsAsConfig is a normalized data object for plugins config data. Any config version should be mappable. +// to this type. +type pluginsAsConfig struct { + Apps []*appFromConfig +} + +type appFromConfig struct { + OrgID int64 + OrgName string + PluginID string + Enabled bool + Pinned bool + PluginVersion string + JSONData map[string]interface{} + SecureJSONData map[string]string +} + +type appFromConfigV0 struct { + OrgID values.Int64Value `json:"org_id" yaml:"org_id"` + OrgName values.StringValue `json:"org_name" yaml:"org_name"` + Type values.StringValue `json:"type" yaml:"type"` + Disabled values.BoolValue `json:"disabled" yaml:"disabled"` + JSONData values.JSONValue `json:"jsonData" yaml:"jsonData"` + SecureJSONData values.StringMapValue `json:"secureJsonData" yaml:"secureJsonData"` +} + +// pluginsAsConfigV0 is a mapping for zero version configs. This is mapped to its normalised version. +type pluginsAsConfigV0 struct { + Apps []*appFromConfigV0 `json:"apps" yaml:"apps"` +} + +// mapToPluginsFromConfig maps config syntax to a normalized notificationsAsConfig object. Every version +// of the config syntax should have this function. +func (cfg *pluginsAsConfigV0) mapToPluginsFromConfig() *pluginsAsConfig { + r := &pluginsAsConfig{} + if cfg == nil { + return r + } + + for _, app := range cfg.Apps { + r.Apps = append(r.Apps, &appFromConfig{ + OrgID: app.OrgID.Value(), + OrgName: app.OrgName.Value(), + PluginID: app.Type.Value(), + Enabled: !app.Disabled.Value(), + Pinned: true, + JSONData: app.JSONData.Value(), + SecureJSONData: app.SecureJSONData.Value(), + }) + } + + return r +} diff --git a/pkg/services/provisioning/provisioning.go b/pkg/services/provisioning/provisioning.go index 54328e13ebe..f296b14a02d 100644 --- a/pkg/services/provisioning/provisioning.go +++ b/pkg/services/provisioning/provisioning.go @@ -6,17 +6,18 @@ import ( "sync" "github.com/grafana/grafana/pkg/infra/log" - "github.com/grafana/grafana/pkg/util/errutil" - "github.com/grafana/grafana/pkg/registry" "github.com/grafana/grafana/pkg/services/provisioning/dashboards" "github.com/grafana/grafana/pkg/services/provisioning/datasources" "github.com/grafana/grafana/pkg/services/provisioning/notifiers" + "github.com/grafana/grafana/pkg/services/provisioning/plugins" "github.com/grafana/grafana/pkg/setting" + "github.com/grafana/grafana/pkg/util/errutil" ) type ProvisioningService interface { ProvisionDatasources() error + ProvisionPlugins() error ProvisionNotifications() error ProvisionDashboards() error GetDashboardProvisionerResolvedPath(name string) string @@ -30,6 +31,7 @@ func init() { }, notifiers.Provision, datasources.Provision, + plugins.Provision, )) } @@ -37,12 +39,14 @@ func NewProvisioningServiceImpl( newDashboardProvisioner dashboards.DashboardProvisionerFactory, provisionNotifiers func(string) error, provisionDatasources func(string) error, + provisionPlugins func(string) error, ) *provisioningServiceImpl { return &provisioningServiceImpl{ log: log.New("provisioning"), newDashboardProvisioner: newDashboardProvisioner, provisionNotifiers: provisionNotifiers, provisionDatasources: provisionDatasources, + provisionPlugins: provisionPlugins, } } @@ -54,6 +58,7 @@ type provisioningServiceImpl struct { dashboardProvisioner dashboards.DashboardProvisioner provisionNotifiers func(string) error provisionDatasources func(string) error + provisionPlugins func(string) error mutex sync.Mutex } @@ -63,6 +68,11 @@ func (ps *provisioningServiceImpl) Init() error { return err } + err = ps.ProvisionPlugins() + if err != nil { + return err + } + err = ps.ProvisionNotifications() if err != nil { return err @@ -107,6 +117,12 @@ func (ps *provisioningServiceImpl) ProvisionDatasources() error { return errutil.Wrap("Datasource provisioning error", err) } +func (ps *provisioningServiceImpl) ProvisionPlugins() error { + appPath := path.Join(ps.Cfg.ProvisioningPath, "plugins") + err := ps.provisionPlugins(appPath) + return errutil.Wrap("app provisioning error", err) +} + func (ps *provisioningServiceImpl) ProvisionNotifications() error { alertNotificationsPath := path.Join(ps.Cfg.ProvisioningPath, "notifiers") err := ps.provisionNotifiers(alertNotificationsPath) diff --git a/pkg/services/provisioning/provisioning_mock.go b/pkg/services/provisioning/provisioning_mock.go index 7e33cf99bc5..0fbb518440c 100644 --- a/pkg/services/provisioning/provisioning_mock.go +++ b/pkg/services/provisioning/provisioning_mock.go @@ -2,6 +2,7 @@ package provisioning type Calls struct { ProvisionDatasources []interface{} + ProvisionPlugins []interface{} ProvisionNotifications []interface{} ProvisionDashboards []interface{} GetDashboardProvisionerResolvedPath []interface{} @@ -11,6 +12,7 @@ type Calls struct { type ProvisioningServiceMock struct { Calls *Calls ProvisionDatasourcesFunc func() error + ProvisionPluginsFunc func() error ProvisionNotificationsFunc func() error ProvisionDashboardsFunc func() error GetDashboardProvisionerResolvedPathFunc func(name string) string @@ -31,6 +33,14 @@ func (mock *ProvisioningServiceMock) ProvisionDatasources() error { return nil } +func (mock *ProvisioningServiceMock) ProvisionPlugins() error { + mock.Calls.ProvisionPlugins = append(mock.Calls.ProvisionPlugins, nil) + if mock.ProvisionPluginsFunc != nil { + return mock.ProvisionPluginsFunc() + } + return nil +} + func (mock *ProvisioningServiceMock) ProvisionNotifications() error { mock.Calls.ProvisionNotifications = append(mock.Calls.ProvisionNotifications, nil) if mock.ProvisionNotificationsFunc != nil { diff --git a/pkg/services/provisioning/provisioning_test.go b/pkg/services/provisioning/provisioning_test.go index 6d7a381cc1a..f1977ee8d7a 100644 --- a/pkg/services/provisioning/provisioning_test.go +++ b/pkg/services/provisioning/provisioning_test.go @@ -97,6 +97,7 @@ func setup() *serviceTestStruct { }, nil, nil, + nil, ) serviceTest.service.Cfg = setting.NewCfg()