Add apiVersion to plugin models (#87510)
This commit is contained in:
@@ -584,6 +584,11 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"apiVersion": {
|
||||
"type": "string",
|
||||
"description": "[internal only] The API version for the plugin. Used for Datasource API servers. This metadata is temporary and will be removed in the future.",
|
||||
"pattern": "^v([\\d]+)(?:(alpha|beta)([\\d]+))?$"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -840,6 +840,7 @@ func (hs *HTTPServer) convertModelToDtos(ctx context.Context, ds *datasources.Da
|
||||
SecureJsonFields: map[string]bool{},
|
||||
Version: ds.Version,
|
||||
ReadOnly: ds.ReadOnly,
|
||||
APIVersion: ds.APIVersion,
|
||||
}
|
||||
|
||||
if hs.pluginStore != nil {
|
||||
|
||||
@@ -28,6 +28,8 @@ type DataSource struct {
|
||||
Version int `json:"version"`
|
||||
ReadOnly bool `json:"readOnly"`
|
||||
AccessControl accesscontrol.Metadata `json:"accessControl,omitempty"`
|
||||
// swagger:ignore
|
||||
APIVersion string `json:"apiVersion"`
|
||||
}
|
||||
|
||||
type DataSourceListItemDTO struct {
|
||||
|
||||
@@ -28,6 +28,7 @@ type PluginSetting struct {
|
||||
SignatureType plugins.SignatureType `json:"signatureType"`
|
||||
SignatureOrg string `json:"signatureOrg"`
|
||||
AngularDetected bool `json:"angularDetected"`
|
||||
APIVersion string `json:"apiVersion"`
|
||||
}
|
||||
|
||||
type PluginListItem struct {
|
||||
@@ -49,6 +50,7 @@ type PluginListItem struct {
|
||||
AccessControl accesscontrol.Metadata `json:"accessControl,omitempty"`
|
||||
AngularDetected bool `json:"angularDetected"`
|
||||
IAM *pfs.IAM `json:"iam,omitempty"`
|
||||
APIVersion string `json:"apiVersion"`
|
||||
}
|
||||
|
||||
type PluginList []PluginListItem
|
||||
|
||||
@@ -142,6 +142,7 @@ func (hs *HTTPServer) GetPluginList(c *contextmodel.ReqContext) response.Respons
|
||||
SignatureOrg: pluginDef.SignatureOrg,
|
||||
AccessControl: pluginsMetadata[pluginDef.ID],
|
||||
AngularDetected: pluginDef.Angular.Detected,
|
||||
APIVersion: pluginDef.APIVersion,
|
||||
}
|
||||
|
||||
if hs.Features.IsEnabled(c.Req.Context(), featuremgmt.FlagExternalServiceAccounts) {
|
||||
@@ -208,6 +209,7 @@ func (hs *HTTPServer) GetPluginSettingByID(c *contextmodel.ReqContext) response.
|
||||
SignatureOrg: plugin.SignatureOrg,
|
||||
SecureJsonFields: map[string]bool{},
|
||||
AngularDetected: plugin.Angular.Detected,
|
||||
APIVersion: plugin.APIVersion,
|
||||
}
|
||||
|
||||
if plugin.IsApp() {
|
||||
|
||||
@@ -3,6 +3,8 @@ package validation
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"slices"
|
||||
"time"
|
||||
|
||||
@@ -115,3 +117,36 @@ func (a *AngularDetector) Validate(ctx context.Context, p *plugins.Plugin) error
|
||||
p.Angular.HideDeprecation = slices.Contains(a.cfg.HideAngularDeprecation, p.ID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// APIVersionValidation implements a ValidateFunc for validating plugin API versions.
|
||||
type APIVersionValidation struct {
|
||||
}
|
||||
|
||||
// APIVersionValidationStep returns a new ValidateFunc for validating plugin signatures.
|
||||
func APIVersionValidationStep() ValidateFunc {
|
||||
sv := &APIVersionValidation{}
|
||||
return sv.Validate
|
||||
}
|
||||
|
||||
// Validate validates the plugin signature. If a signature error is encountered, the error is recorded with the
|
||||
// pluginerrs.ErrorTracker.
|
||||
func (v *APIVersionValidation) Validate(ctx context.Context, p *plugins.Plugin) error {
|
||||
if p.APIVersion != "" {
|
||||
if !p.Backend {
|
||||
return fmt.Errorf("plugin %s has an API version but is not a backend plugin", p.ID)
|
||||
}
|
||||
// Eventually, all backend plugins will be supported
|
||||
if p.Type != plugins.TypeDataSource {
|
||||
return fmt.Errorf("plugin %s has an API version but is not a datasource plugin", p.ID)
|
||||
}
|
||||
m, err := regexp.MatchString(`^v([\d]+)(?:(alpha|beta)([\d]+))?$`, p.APIVersion)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to verify apiVersion %s: %v", p.APIVersion, err)
|
||||
}
|
||||
if !m {
|
||||
return fmt.Errorf("plugin %s has an invalid API version %s", p.ID, p.APIVersion)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
package validation
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/grafana/grafana/pkg/plugins"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestAPIVersionValidation(t *testing.T) {
|
||||
s := APIVersionValidationStep()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
plugin *plugins.Plugin
|
||||
err bool
|
||||
}{
|
||||
{
|
||||
name: "valid plugin",
|
||||
plugin: &plugins.Plugin{
|
||||
JSONData: plugins.JSONData{
|
||||
Backend: true,
|
||||
Type: plugins.TypeDataSource,
|
||||
APIVersion: "v0alpha1",
|
||||
},
|
||||
},
|
||||
err: false,
|
||||
},
|
||||
{
|
||||
name: "invalid plugin - not backend",
|
||||
plugin: &plugins.Plugin{
|
||||
JSONData: plugins.JSONData{
|
||||
Backend: false,
|
||||
Type: plugins.TypeDataSource,
|
||||
APIVersion: "v0alpha1",
|
||||
},
|
||||
},
|
||||
err: true,
|
||||
},
|
||||
{
|
||||
name: "invalid plugin - not datasource",
|
||||
plugin: &plugins.Plugin{
|
||||
JSONData: plugins.JSONData{
|
||||
Backend: true,
|
||||
Type: plugins.TypeApp,
|
||||
APIVersion: "v0alpha1",
|
||||
},
|
||||
},
|
||||
err: true,
|
||||
},
|
||||
{
|
||||
name: "invalid plugin - invalid API version",
|
||||
plugin: &plugins.Plugin{
|
||||
JSONData: plugins.JSONData{
|
||||
Backend: true,
|
||||
Type: plugins.TypeDataSource,
|
||||
APIVersion: "invalid",
|
||||
},
|
||||
},
|
||||
err: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := s(context.Background(), tt.plugin)
|
||||
if tt.err {
|
||||
require.Error(t, err)
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -119,6 +119,9 @@ type JSONData struct {
|
||||
|
||||
// App Service Auth Registration
|
||||
IAM *pfs.IAM `json:"iam,omitempty"`
|
||||
|
||||
// API Version: Temporary field while plugins don't expose a OpenAPI schema
|
||||
APIVersion string `json:"apiVersion,omitempty"`
|
||||
}
|
||||
|
||||
func ReadPluginJSON(reader io.Reader) (JSONData, error) {
|
||||
|
||||
@@ -17,4 +17,5 @@ var (
|
||||
ErrDatasourceIsReadOnly = errors.New("data source is readonly, can only be updated from configuration")
|
||||
ErrDataSourceNameInvalid = errutil.ValidationFailed("datasource.nameInvalid", errutil.WithPublicMessage("Invalid datasource name."))
|
||||
ErrDataSourceURLInvalid = errutil.ValidationFailed("datasource.urlInvalid", errutil.WithPublicMessage("Invalid datasource url."))
|
||||
ErrDataSourceAPIVersionInvalid = errutil.ValidationFailed("datasource.apiVersionInvalid", errutil.WithPublicMessage("Invalid datasource apiVersion."))
|
||||
)
|
||||
|
||||
@@ -63,6 +63,8 @@ type DataSource struct {
|
||||
ReadOnly bool `json:"readOnly"`
|
||||
UID string `json:"uid" xorm:"uid"`
|
||||
// swagger:ignore
|
||||
APIVersion string `json:"apiVersion" xorm:"api_version"`
|
||||
// swagger:ignore
|
||||
IsPrunable bool `xorm:"is_prunable"`
|
||||
|
||||
Created time.Time `json:"created,omitempty"`
|
||||
@@ -164,6 +166,8 @@ type AddDataSourceCommand struct {
|
||||
SecureJsonData map[string]string `json:"secureJsonData"`
|
||||
UID string `json:"uid"`
|
||||
// swagger:ignore
|
||||
APIVersion string `json:"apiVersion"`
|
||||
// swagger:ignore
|
||||
IsPrunable bool
|
||||
|
||||
OrgID int64 `json:"-"`
|
||||
@@ -190,6 +194,8 @@ type UpdateDataSourceCommand struct {
|
||||
Version int `json:"version"`
|
||||
UID string `json:"uid"`
|
||||
// swagger:ignore
|
||||
APIVersion string `json:"apiVersion"`
|
||||
// swagger:ignore
|
||||
IsPrunable bool
|
||||
|
||||
OrgID int64 `json:"-"`
|
||||
|
||||
@@ -205,7 +205,7 @@ func (s *Service) AddDataSource(ctx context.Context, cmd *datasources.AddDataSou
|
||||
cmd.Name = getAvailableName(cmd.Type, dataSources)
|
||||
}
|
||||
|
||||
if err := validateFields(cmd.Name, cmd.URL); err != nil {
|
||||
if err := s.validateFields(ctx, cmd.Name, cmd.URL, cmd.Type, cmd.APIVersion); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -287,7 +287,7 @@ func (s *Service) DeleteDataSource(ctx context.Context, cmd *datasources.DeleteD
|
||||
func (s *Service) UpdateDataSource(ctx context.Context, cmd *datasources.UpdateDataSourceCommand) (*datasources.DataSource, error) {
|
||||
var dataSource *datasources.DataSource
|
||||
|
||||
if err := validateFields(cmd.Name, cmd.URL); err != nil {
|
||||
if err := s.validateFields(ctx, cmd.Name, cmd.URL, cmd.Type, cmd.APIVersion); err != nil {
|
||||
return dataSource, err
|
||||
}
|
||||
|
||||
@@ -716,7 +716,7 @@ func (s *Service) fillWithSecureJSONData(ctx context.Context, cmd *datasources.U
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateFields(name, url string) error {
|
||||
func (s *Service) validateFields(ctx context.Context, name, url, pluginID, apiVersion string) error {
|
||||
if len(name) > maxDatasourceNameLen {
|
||||
return datasources.ErrDataSourceNameInvalid.Errorf("max length is %d", maxDatasourceNameLen)
|
||||
}
|
||||
@@ -725,6 +725,20 @@ func validateFields(name, url string) error {
|
||||
return datasources.ErrDataSourceURLInvalid.Errorf("max length is %d", maxDatasourceUrlLen)
|
||||
}
|
||||
|
||||
if apiVersion == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
p, found := s.pluginStore.Plugin(context.Background(), pluginID)
|
||||
if !found {
|
||||
// Plugin not installed, ignore apiVersion check
|
||||
return nil
|
||||
}
|
||||
|
||||
if p.APIVersion != "" && p.APIVersion != apiVersion {
|
||||
return datasources.ErrDataSourceAPIVersionInvalid.Errorf("expected %s, got %s", p.APIVersion, apiVersion)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ import (
|
||||
"github.com/grafana/grafana/pkg/infra/db"
|
||||
"github.com/grafana/grafana/pkg/infra/httpclient"
|
||||
"github.com/grafana/grafana/pkg/infra/log"
|
||||
"github.com/grafana/grafana/pkg/plugins"
|
||||
"github.com/grafana/grafana/pkg/services/accesscontrol"
|
||||
"github.com/grafana/grafana/pkg/services/accesscontrol/actest"
|
||||
acmock "github.com/grafana/grafana/pkg/services/accesscontrol/mock"
|
||||
@@ -63,7 +64,14 @@ func TestService_AddDataSource(t *testing.T) {
|
||||
secretsStore := secretskvs.NewSQLSecretsKVStore(sqlStore, secretsService, log.New("test.logger"))
|
||||
quotaService := quotatest.New(false, nil)
|
||||
mockPermission := acmock.NewMockedPermissionsService()
|
||||
dsService, err := ProvideService(sqlStore, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), actest.FakeAccessControl{}, mockPermission, quotaService, &pluginstore.FakePluginStore{})
|
||||
dsService, err := ProvideService(sqlStore, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), actest.FakeAccessControl{}, mockPermission, quotaService, &pluginstore.FakePluginStore{
|
||||
PluginList: []pluginstore.Plugin{{
|
||||
JSONData: plugins.JSONData{
|
||||
Name: "test",
|
||||
APIVersion: "v0alpha1",
|
||||
},
|
||||
}},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
cmd := &datasources.AddDataSourceCommand{
|
||||
@@ -81,6 +89,15 @@ func TestService_AddDataSource(t *testing.T) {
|
||||
|
||||
_, err = dsService.AddDataSource(context.Background(), cmd)
|
||||
require.EqualError(t, err, "[datasource.urlInvalid] max length is 255")
|
||||
|
||||
cmd = &datasources.AddDataSourceCommand{
|
||||
OrgID: 1,
|
||||
Name: "test",
|
||||
APIVersion: "v0alpha2",
|
||||
}
|
||||
|
||||
_, err = dsService.AddDataSource(context.Background(), cmd)
|
||||
require.EqualError(t, err, "[datasource.apiVersionInvalid] expected v0alpha1, got v0alpha2")
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -284,6 +284,7 @@ func (ss *SqlStore) AddDataSource(ctx context.Context, cmd *datasources.AddDataS
|
||||
ReadOnly: cmd.ReadOnly,
|
||||
UID: cmd.UID,
|
||||
IsPrunable: cmd.IsPrunable,
|
||||
APIVersion: cmd.APIVersion,
|
||||
}
|
||||
|
||||
if _, err := sess.Insert(ds); err != nil {
|
||||
@@ -361,6 +362,7 @@ func (ss *SqlStore) UpdateDataSource(ctx context.Context, cmd *datasources.Updat
|
||||
Version: cmd.Version + 1,
|
||||
UID: cmd.UID,
|
||||
IsPrunable: cmd.IsPrunable,
|
||||
APIVersion: cmd.APIVersion,
|
||||
}
|
||||
|
||||
sess.UseBool("is_default")
|
||||
@@ -378,6 +380,7 @@ func (ss *SqlStore) UpdateDataSource(ctx context.Context, cmd *datasources.Updat
|
||||
// Make sure secure json data is zeroed out if empty. We do this as we want to migrate secrets from
|
||||
// secure json data to the unified secrets table.
|
||||
sess.MustCols("secure_json_data")
|
||||
sess.MustCols("api_version")
|
||||
|
||||
var updateSession *xorm.Session
|
||||
if cmd.Version != 0 {
|
||||
|
||||
@@ -56,13 +56,14 @@ func TestIntegrationDataAccess(t *testing.T) {
|
||||
db := db.InitTestDB(t)
|
||||
ss := SqlStore{db: db}
|
||||
_, err := ss.AddDataSource(context.Background(), &datasources.AddDataSourceCommand{
|
||||
OrgID: 10,
|
||||
Name: "laban",
|
||||
Type: datasources.DS_GRAPHITE,
|
||||
Access: datasources.DS_ACCESS_DIRECT,
|
||||
URL: "http://test",
|
||||
Database: "site",
|
||||
ReadOnly: true,
|
||||
OrgID: 10,
|
||||
Name: "laban",
|
||||
Type: datasources.DS_GRAPHITE,
|
||||
Access: datasources.DS_ACCESS_DIRECT,
|
||||
URL: "http://test",
|
||||
Database: "site",
|
||||
ReadOnly: true,
|
||||
APIVersion: "v0alpha1",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -76,6 +77,7 @@ func TestIntegrationDataAccess(t *testing.T) {
|
||||
require.EqualValues(t, 10, ds.OrgID)
|
||||
require.Equal(t, "site", ds.Database)
|
||||
require.True(t, ds.ReadOnly)
|
||||
require.Equal(t, "v0alpha1", ds.APIVersion)
|
||||
})
|
||||
|
||||
t.Run("generates uid if not specified", func(t *testing.T) {
|
||||
@@ -146,9 +148,11 @@ func TestIntegrationDataAccess(t *testing.T) {
|
||||
cmd := defaultUpdateDatasourceCommand
|
||||
cmd.ID = ds.ID
|
||||
cmd.Version = ds.Version
|
||||
cmd.APIVersion = "v0alpha1"
|
||||
ss := SqlStore{db: db}
|
||||
_, err := ss.UpdateDataSource(context.Background(), &cmd)
|
||||
ds, err := ss.UpdateDataSource(context.Background(), &cmd)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "v0alpha1", ds.APIVersion)
|
||||
})
|
||||
|
||||
t.Run("does not overwrite UID if not specified", func(t *testing.T) {
|
||||
|
||||
@@ -40,6 +40,7 @@ func ModelToInstanceSettings(ds *datasources.DataSource, decryptFn func(ds *data
|
||||
JSONData: jsonDataBytes,
|
||||
DecryptedSecureJSONData: decrypted,
|
||||
Updated: ds.Updated,
|
||||
APIVersion: ds.APIVersion,
|
||||
}, err
|
||||
}
|
||||
|
||||
|
||||
@@ -54,6 +54,7 @@ func ProvideValidationStage(cfg *config.PluginManagementCfg, sv signature.Valida
|
||||
SignatureValidationStep(sv),
|
||||
validation.ModuleJSValidationStep(),
|
||||
validation.AngularDetectionStep(cfg, ai),
|
||||
validation.APIVersionValidationStep(),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -68,6 +68,7 @@ func (p *Provider) Get(ctx context.Context, pluginID string, user identity.Reque
|
||||
pCtx := backend.PluginContext{
|
||||
PluginID: plugin.ID,
|
||||
PluginVersion: plugin.Info.Version,
|
||||
APIVersion: plugin.APIVersion,
|
||||
}
|
||||
if user != nil && !user.IsNil() {
|
||||
pCtx.OrgID = user.GetOrgID()
|
||||
@@ -107,6 +108,7 @@ func (p *Provider) GetWithDataSource(ctx context.Context, pluginID string, user
|
||||
pCtx := backend.PluginContext{
|
||||
PluginID: plugin.ID,
|
||||
PluginVersion: plugin.Info.Version,
|
||||
APIVersion: plugin.APIVersion,
|
||||
}
|
||||
if user != nil && !user.IsNil() {
|
||||
pCtx.OrgID = user.GetOrgID()
|
||||
@@ -159,6 +161,7 @@ func (p *Provider) PluginContextForDataSource(ctx context.Context, datasourceSet
|
||||
pCtx := backend.PluginContext{
|
||||
PluginID: plugin.ID,
|
||||
PluginVersion: plugin.Info.Version,
|
||||
APIVersion: plugin.APIVersion,
|
||||
}
|
||||
if user != nil && !user.IsNil() {
|
||||
pCtx.OrgID = user.GetOrgID()
|
||||
|
||||
@@ -26,15 +26,17 @@ import (
|
||||
|
||||
func TestGet(t *testing.T) {
|
||||
const (
|
||||
pluginID = "plugin-id"
|
||||
alias = "alias"
|
||||
pluginID = "plugin-id"
|
||||
alias = "alias"
|
||||
apiVersion = "v0alpha1"
|
||||
)
|
||||
|
||||
preg := registry.NewInMemory()
|
||||
require.NoError(t, preg.Add(context.Background(), &plugins.Plugin{
|
||||
JSONData: plugins.JSONData{
|
||||
ID: pluginID,
|
||||
AliasIDs: []string{alias},
|
||||
ID: pluginID,
|
||||
AliasIDs: []string{alias},
|
||||
APIVersion: apiVersion,
|
||||
},
|
||||
}))
|
||||
|
||||
@@ -59,6 +61,7 @@ func TestGet(t *testing.T) {
|
||||
pCtx, err := pcp.Get(context.Background(), tc.input, identity, identity.OrgID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, pluginID, pCtx.PluginID)
|
||||
require.Equal(t, apiVersion, pCtx.APIVersion)
|
||||
require.NotNil(t, pCtx.GrafanaConfig)
|
||||
})
|
||||
|
||||
@@ -72,6 +75,7 @@ func TestGet(t *testing.T) {
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, pluginID, pCtx.PluginID)
|
||||
require.Equal(t, apiVersion, pCtx.APIVersion)
|
||||
require.NotNil(t, pCtx.GrafanaConfig)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -138,4 +138,8 @@ func addDataSourceMigration(mg *Migrator) {
|
||||
mg.AddMigration("Add is_prunable column", NewAddColumnMigration(tableV2, &Column{
|
||||
Name: "is_prunable", Type: DB_Bool, Nullable: true, Default: "0",
|
||||
}))
|
||||
|
||||
mg.AddMigration("Add api_version column", NewAddColumnMigration(tableV2, &Column{
|
||||
Name: "api_version", Type: DB_Varchar, Nullable: true, Length: 20,
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -1668,7 +1668,8 @@
|
||||
"signature": "internal",
|
||||
"signatureType": "",
|
||||
"signatureOrg": "",
|
||||
"angularDetected": false
|
||||
"angularDetected": false,
|
||||
"apiVersion": "v0alpha1"
|
||||
},
|
||||
{
|
||||
"name": "Text",
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
"alerting": true,
|
||||
"annotations": true,
|
||||
"backend": true,
|
||||
"apiVersion": "v0alpha1",
|
||||
|
||||
"queryOptions": {
|
||||
"minInterval": true,
|
||||
|
||||
Reference in New Issue
Block a user