Commit cc95754e by Marcus Efraimsson Committed by GitHub

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>
parent 602dd1e2
# # 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
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:
# <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
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:
......
......@@ -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
......
......@@ -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
......
......@@ -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
......
......@@ -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 {
......
......@@ -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))
......
......@@ -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
}
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
}
}
}
}
}
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)
}
})
}
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
}
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
}
#sfxzgnsxzcvnbzcvn
cvbn
cvbn
c
vbn
cvbncvbn
\ No newline at end of file
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
# Ignore everything in this directory
*
# Except this file
!.gitignore
\ No newline at end of file
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"
"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)
......
......@@ -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 {
......
......@@ -97,6 +97,7 @@ func setup() *serviceTestStruct {
},
nil,
nil,
nil,
)
serviceTest.service.Cfg = setting.NewCfg()
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment