mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Provisioning: Adds support for enabling app plugins (#25649)
Adds support for enabling app plugins using provisioning. Ref #11409 Co-authored-by: Arve Knudsen <arve.knudsen@gmail.com> Co-authored-by: Diana Payton <52059945+oddlittlebird@users.noreply.github.com>
This commit is contained in:
parent
602dd1e226
commit
cc95754e0d
11
conf/provisioning/plugins/sample.yaml
Normal file
11
conf/provisioning/plugins/sample.yaml
Normal file
@ -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"
|
@ -65,7 +65,7 @@ Currently we do not provide any scripts/manifests for configuring Grafana. Rathe
|
|||||||
|
|
||||||
> This feature is available from v5.0
|
> 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
|
### Running Multiple Grafana Instances
|
||||||
|
|
||||||
@ -208,9 +208,39 @@ datasources:
|
|||||||
httpHeaderValue2: 'Bearer XXXXXXXXX'
|
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:
|
||||||
|
# <string> the type of app, plugin identifier. Required
|
||||||
|
- type: raintank-worldping-app
|
||||||
|
# <int> Org ID. Default to 1, unless org_name is specified
|
||||||
|
org_id: 1
|
||||||
|
# <string> Org name. Overrides org_id unless org_id not specified
|
||||||
|
org_name: Main Org.
|
||||||
|
# <bool> disable the app. Default to false.
|
||||||
|
disabled: false
|
||||||
|
# <map> fields that will be converted to json and stored in jsonData. Custom per app.
|
||||||
|
jsonData:
|
||||||
|
# key/value pairs of string to object
|
||||||
|
key: value
|
||||||
|
# <map> 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
|
## 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:
|
The dashboard provider config file looks somewhat like this:
|
||||||
|
|
||||||
|
@ -465,6 +465,8 @@ Content-Type: application/json
|
|||||||
|
|
||||||
`POST /api/admin/provisioning/datasources/reload`
|
`POST /api/admin/provisioning/datasources/reload`
|
||||||
|
|
||||||
|
`POST /api/admin/provisioning/plugins/reload`
|
||||||
|
|
||||||
`POST /api/admin/provisioning/notifications/reload`
|
`POST /api/admin/provisioning/notifications/reload`
|
||||||
|
|
||||||
Reloads the provisioning config files for specified type and provision entities again. It won't return
|
Reloads the provisioning config files for specified type and provision entities again. It won't return
|
||||||
|
@ -42,6 +42,11 @@ case "$1" in
|
|||||||
cp /usr/share/grafana/conf/provisioning/notifiers/sample.yaml $PROVISIONING_CFG_DIR/notifiers/sample.yaml
|
cp /usr/share/grafana/conf/provisioning/notifiers/sample.yaml $PROVISIONING_CFG_DIR/notifiers/sample.yaml
|
||||||
fi
|
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
|
# configuration files should not be modifiable by grafana user, as this can be a security issue
|
||||||
chown -Rh root:$GRAFANA_GROUP /etc/grafana/*
|
chown -Rh root:$GRAFANA_GROUP /etc/grafana/*
|
||||||
chmod 755 /etc/grafana
|
chmod 755 /etc/grafana
|
||||||
|
@ -56,6 +56,11 @@ if [ $1 -eq 1 ] ; then
|
|||||||
cp /usr/share/grafana/conf/provisioning/notifiers/sample.yaml $PROVISIONING_CFG_DIR/notifiers/sample.yaml
|
cp /usr/share/grafana/conf/provisioning/notifiers/sample.yaml $PROVISIONING_CFG_DIR/notifiers/sample.yaml
|
||||||
fi
|
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
|
# Set user permissions on /var/log/grafana, /var/lib/grafana
|
||||||
mkdir -p /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
|
chown -R $GRAFANA_USER:$GRAFANA_GROUP /var/log/grafana /var/lib/grafana
|
||||||
|
@ -2,6 +2,7 @@ package api
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
|
||||||
"github.com/grafana/grafana/pkg/models"
|
"github.com/grafana/grafana/pkg/models"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -21,6 +22,14 @@ func (server *HTTPServer) AdminProvisioningReloadDatasources(c *models.ReqContex
|
|||||||
return Success("Datasources config reloaded")
|
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 {
|
func (server *HTTPServer) AdminProvisioningReloadNotifications(c *models.ReqContext) Response {
|
||||||
err := server.ProvisioningService.ProvisionNotifications()
|
err := server.ProvisioningService.ProvisionNotifications()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
@ -405,6 +405,7 @@ func (hs *HTTPServer) registerRoutes() {
|
|||||||
adminRoute.Post("/users/:id/revoke-auth-token", bind(models.RevokeAuthTokenCmd{}), Wrap(hs.AdminRevokeUserAuthToken))
|
adminRoute.Post("/users/:id/revoke-auth-token", bind(models.RevokeAuthTokenCmd{}), Wrap(hs.AdminRevokeUserAuthToken))
|
||||||
|
|
||||||
adminRoute.Post("/provisioning/dashboards/reload", Wrap(hs.AdminProvisioningReloadDashboards))
|
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/datasources/reload", Wrap(hs.AdminProvisioningReloadDatasources))
|
||||||
adminRoute.Post("/provisioning/notifications/reload", Wrap(hs.AdminProvisioningReloadNotifications))
|
adminRoute.Post("/provisioning/notifications/reload", Wrap(hs.AdminProvisioningReloadNotifications))
|
||||||
adminRoute.Post("/ldap/reload", Wrap(hs.ReloadLDAPCfg))
|
adminRoute.Post("/ldap/reload", Wrap(hs.ReloadLDAPCfg))
|
||||||
|
@ -85,3 +85,9 @@ func GetEnabledPlugins(orgId int64) (*EnabledPlugins, error) {
|
|||||||
|
|
||||||
return &enabledPlugins, nil
|
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
|
||||||
|
}
|
||||||
|
134
pkg/services/provisioning/plugins/config_reader.go
Normal file
134
pkg/services/provisioning/plugins/config_reader.go
Normal file
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
86
pkg/services/provisioning/plugins/config_reader_test.go
Normal file
86
pkg/services/provisioning/plugins/config_reader_test.go
Normal file
@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
84
pkg/services/provisioning/plugins/plugin_provisioner.go
Normal file
84
pkg/services/provisioning/plugins/plugin_provisioner.go
Normal file
@ -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
|
||||||
|
}
|
95
pkg/services/provisioning/plugins/plugin_provisioner_test.go
Normal file
95
pkg/services/provisioning/plugins/plugin_provisioner_test.go
Normal file
@ -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
|
||||||
|
}
|
4
pkg/services/provisioning/plugins/testdata/test-configs/broken-yaml/broken.yaml
vendored
Normal file
4
pkg/services/provisioning/plugins/testdata/test-configs/broken-yaml/broken.yaml
vendored
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
apps:
|
||||||
|
- type: something
|
||||||
|
org_id: 2
|
||||||
|
disabled: false
|
6
pkg/services/provisioning/plugins/testdata/test-configs/broken-yaml/not.yaml.text
vendored
Normal file
6
pkg/services/provisioning/plugins/testdata/test-configs/broken-yaml/not.yaml.text
vendored
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
#sfxzgnsxzcvnbzcvn
|
||||||
|
cvbn
|
||||||
|
cvbn
|
||||||
|
c
|
||||||
|
vbn
|
||||||
|
cvbncvbn
|
@ -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
|
4
pkg/services/provisioning/plugins/testdata/test-configs/empty_folder/.gitignore
vendored
Normal file
4
pkg/services/provisioning/plugins/testdata/test-configs/empty_folder/.gitignore
vendored
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
# Ignore everything in this directory
|
||||||
|
*
|
||||||
|
# Except this file
|
||||||
|
!.gitignore
|
@ -0,0 +1,3 @@
|
|||||||
|
apps:
|
||||||
|
- org_id: 2
|
||||||
|
disabled: false
|
2
pkg/services/provisioning/plugins/testdata/test-configs/unknown-app/unknown-app.yaml
vendored
Normal file
2
pkg/services/provisioning/plugins/testdata/test-configs/unknown-app/unknown-app.yaml
vendored
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
apps:
|
||||||
|
- type: nonexisting
|
57
pkg/services/provisioning/plugins/types.go
Normal file
57
pkg/services/provisioning/plugins/types.go
Normal file
@ -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
|
||||||
|
}
|
@ -6,17 +6,18 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
"github.com/grafana/grafana/pkg/infra/log"
|
"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/registry"
|
||||||
"github.com/grafana/grafana/pkg/services/provisioning/dashboards"
|
"github.com/grafana/grafana/pkg/services/provisioning/dashboards"
|
||||||
"github.com/grafana/grafana/pkg/services/provisioning/datasources"
|
"github.com/grafana/grafana/pkg/services/provisioning/datasources"
|
||||||
"github.com/grafana/grafana/pkg/services/provisioning/notifiers"
|
"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/setting"
|
||||||
|
"github.com/grafana/grafana/pkg/util/errutil"
|
||||||
)
|
)
|
||||||
|
|
||||||
type ProvisioningService interface {
|
type ProvisioningService interface {
|
||||||
ProvisionDatasources() error
|
ProvisionDatasources() error
|
||||||
|
ProvisionPlugins() error
|
||||||
ProvisionNotifications() error
|
ProvisionNotifications() error
|
||||||
ProvisionDashboards() error
|
ProvisionDashboards() error
|
||||||
GetDashboardProvisionerResolvedPath(name string) string
|
GetDashboardProvisionerResolvedPath(name string) string
|
||||||
@ -30,6 +31,7 @@ func init() {
|
|||||||
},
|
},
|
||||||
notifiers.Provision,
|
notifiers.Provision,
|
||||||
datasources.Provision,
|
datasources.Provision,
|
||||||
|
plugins.Provision,
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -37,12 +39,14 @@ func NewProvisioningServiceImpl(
|
|||||||
newDashboardProvisioner dashboards.DashboardProvisionerFactory,
|
newDashboardProvisioner dashboards.DashboardProvisionerFactory,
|
||||||
provisionNotifiers func(string) error,
|
provisionNotifiers func(string) error,
|
||||||
provisionDatasources func(string) error,
|
provisionDatasources func(string) error,
|
||||||
|
provisionPlugins func(string) error,
|
||||||
) *provisioningServiceImpl {
|
) *provisioningServiceImpl {
|
||||||
return &provisioningServiceImpl{
|
return &provisioningServiceImpl{
|
||||||
log: log.New("provisioning"),
|
log: log.New("provisioning"),
|
||||||
newDashboardProvisioner: newDashboardProvisioner,
|
newDashboardProvisioner: newDashboardProvisioner,
|
||||||
provisionNotifiers: provisionNotifiers,
|
provisionNotifiers: provisionNotifiers,
|
||||||
provisionDatasources: provisionDatasources,
|
provisionDatasources: provisionDatasources,
|
||||||
|
provisionPlugins: provisionPlugins,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -54,6 +58,7 @@ type provisioningServiceImpl struct {
|
|||||||
dashboardProvisioner dashboards.DashboardProvisioner
|
dashboardProvisioner dashboards.DashboardProvisioner
|
||||||
provisionNotifiers func(string) error
|
provisionNotifiers func(string) error
|
||||||
provisionDatasources func(string) error
|
provisionDatasources func(string) error
|
||||||
|
provisionPlugins func(string) error
|
||||||
mutex sync.Mutex
|
mutex sync.Mutex
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -63,6 +68,11 @@ func (ps *provisioningServiceImpl) Init() error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
err = ps.ProvisionPlugins()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
err = ps.ProvisionNotifications()
|
err = ps.ProvisionNotifications()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@ -107,6 +117,12 @@ func (ps *provisioningServiceImpl) ProvisionDatasources() error {
|
|||||||
return errutil.Wrap("Datasource provisioning error", err)
|
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 {
|
func (ps *provisioningServiceImpl) ProvisionNotifications() error {
|
||||||
alertNotificationsPath := path.Join(ps.Cfg.ProvisioningPath, "notifiers")
|
alertNotificationsPath := path.Join(ps.Cfg.ProvisioningPath, "notifiers")
|
||||||
err := ps.provisionNotifiers(alertNotificationsPath)
|
err := ps.provisionNotifiers(alertNotificationsPath)
|
||||||
|
@ -2,6 +2,7 @@ package provisioning
|
|||||||
|
|
||||||
type Calls struct {
|
type Calls struct {
|
||||||
ProvisionDatasources []interface{}
|
ProvisionDatasources []interface{}
|
||||||
|
ProvisionPlugins []interface{}
|
||||||
ProvisionNotifications []interface{}
|
ProvisionNotifications []interface{}
|
||||||
ProvisionDashboards []interface{}
|
ProvisionDashboards []interface{}
|
||||||
GetDashboardProvisionerResolvedPath []interface{}
|
GetDashboardProvisionerResolvedPath []interface{}
|
||||||
@ -11,6 +12,7 @@ type Calls struct {
|
|||||||
type ProvisioningServiceMock struct {
|
type ProvisioningServiceMock struct {
|
||||||
Calls *Calls
|
Calls *Calls
|
||||||
ProvisionDatasourcesFunc func() error
|
ProvisionDatasourcesFunc func() error
|
||||||
|
ProvisionPluginsFunc func() error
|
||||||
ProvisionNotificationsFunc func() error
|
ProvisionNotificationsFunc func() error
|
||||||
ProvisionDashboardsFunc func() error
|
ProvisionDashboardsFunc func() error
|
||||||
GetDashboardProvisionerResolvedPathFunc func(name string) string
|
GetDashboardProvisionerResolvedPathFunc func(name string) string
|
||||||
@ -31,6 +33,14 @@ func (mock *ProvisioningServiceMock) ProvisionDatasources() error {
|
|||||||
return nil
|
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 {
|
func (mock *ProvisioningServiceMock) ProvisionNotifications() error {
|
||||||
mock.Calls.ProvisionNotifications = append(mock.Calls.ProvisionNotifications, nil)
|
mock.Calls.ProvisionNotifications = append(mock.Calls.ProvisionNotifications, nil)
|
||||||
if mock.ProvisionNotificationsFunc != nil {
|
if mock.ProvisionNotificationsFunc != nil {
|
||||||
|
@ -97,6 +97,7 @@ func setup() *serviceTestStruct {
|
|||||||
},
|
},
|
||||||
nil,
|
nil,
|
||||||
nil,
|
nil,
|
||||||
|
nil,
|
||||||
)
|
)
|
||||||
serviceTest.service.Cfg = setting.NewCfg()
|
serviceTest.service.Cfg = setting.NewCfg()
|
||||||
|
|
||||||
|
Loading…
Reference in New Issue
Block a user