Plugins: Add /meta and /metas APIs to plugins app (#113775)

* add /meta and /metas APIs

* wrapped storage route

* format file

* fix switch statement lint issue

* fix plugininstaller test

---------

Co-authored-by: Todd Treece <todd.treece@grafana.com>
This commit is contained in:
Will Browne
2025-11-24 18:20:11 +00:00
committed by GitHub
parent 335111e783
commit f1dbbcbe00
33 changed files with 4057 additions and 1041 deletions
+131
View File
@@ -0,0 +1,131 @@
package meta
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/url"
"path"
"time"
"github.com/grafana/grafana-app-sdk/logging"
pluginsv0alpha1 "github.com/grafana/grafana/apps/plugins/pkg/apis/plugins/v0alpha1"
)
const (
defaultCloudTTL = 1 * time.Hour
)
// CloudProvider retrieves plugin metadata from the grafana.com API.
type CloudProvider struct {
httpClient *http.Client
grafanaComAPIURL string
log logging.Logger
ttl time.Duration
}
// NewCloudProvider creates a new CloudProvider that fetches metadata from grafana.com.
func NewCloudProvider(grafanaComAPIURL string) *CloudProvider {
return NewCloudProviderWithTTL(grafanaComAPIURL, defaultCloudTTL)
}
// NewCloudProviderWithTTL creates a new CloudProvider with a custom TTL.
func NewCloudProviderWithTTL(grafanaComAPIURL string, ttl time.Duration) *CloudProvider {
if grafanaComAPIURL == "" {
grafanaComAPIURL = "https://grafana.com/api/plugins"
}
return &CloudProvider{
httpClient: &http.Client{
Timeout: 10 * time.Second,
},
grafanaComAPIURL: grafanaComAPIURL,
log: logging.DefaultLogger,
ttl: ttl,
}
}
// GetMeta fetches plugin metadata from grafana.com API endpoint:
// GET /api/plugins/{pluginId}/versions/{version}
func (p *CloudProvider) GetMeta(ctx context.Context, pluginID, version string) (*Result, error) {
u, err := url.Parse(p.grafanaComAPIURL)
if err != nil {
return nil, fmt.Errorf("invalid grafana.com API URL: %w", err)
}
u.Path = path.Join(u.Path, pluginID, "versions", version)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Accept", "application/json")
req.Header.Set("User-Agent", "grafana-plugins-app")
resp, err := p.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to fetch plugin metadata: %w", err)
}
defer func() {
if err = resp.Body.Close(); err != nil {
p.log.Warn("Failed to close response body", "error", err)
}
}()
if resp.StatusCode == http.StatusNotFound {
return nil, ErrMetaNotFound
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("unexpected status code %d from grafana.com API", resp.StatusCode)
}
var gcomMeta grafanaComPluginVersionMeta
if err = json.NewDecoder(resp.Body).Decode(&gcomMeta); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
}
return &Result{
Meta: gcomMeta.JSON,
TTL: p.ttl,
}, nil
}
// grafanaComPluginVersionMeta represents the response from grafana.com API
// GET /api/plugins/{pluginId}/versions/{version}
type grafanaComPluginVersionMeta struct {
PluginID string `json:"pluginSlug"`
Version string `json:"version"`
URL string `json:"url"`
Commit string `json:"commit"`
Description string `json:"description"`
Keywords []string `json:"keywords"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
JSON pluginsv0alpha1.PluginMetaJSONData `json:"json"`
Readme string `json:"readme"`
Downloads int `json:"downloads"`
Verified bool `json:"verified"`
Status string `json:"status"`
StatusContext string `json:"statusContext"`
DownloadSlug string `json:"downloadSlug"`
SignatureType string `json:"signatureType"`
SignedByOrg string `json:"signedByOrg"`
SignedByOrgName string `json:"signedByOrgName"`
Packages struct {
Any struct {
Md5 string `json:"md5"`
Sha256 string `json:"sha256"`
PackageName string `json:"packageName"`
DownloadURL string `json:"downloadUrl"`
} `json:"any"`
} `json:"packages"`
Links []struct {
Rel string `json:"rel"`
Href string `json:"href"`
} `json:"links"`
AngularDetected bool `json:"angularDetected"`
Scopes []string `json:"scopes"`
}
+186
View File
@@ -0,0 +1,186 @@
package meta
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
pluginsv0alpha1 "github.com/grafana/grafana/apps/plugins/pkg/apis/plugins/v0alpha1"
)
func TestCloudProvider_GetMeta(t *testing.T) {
ctx := context.Background()
t.Run("successfully fetches plugin metadata", func(t *testing.T) {
expectedMeta := pluginsv0alpha1.PluginMetaJSONData{
Id: "test-plugin",
Name: "Test Plugin",
Type: pluginsv0alpha1.PluginMetaJSONDataTypeDatasource,
}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, http.MethodGet, r.Method)
assert.Equal(t, "/api/plugins/test-plugin/versions/1.0.0", r.URL.Path)
assert.Equal(t, "application/json", r.Header.Get("Accept"))
assert.Equal(t, "grafana-plugins-app", r.Header.Get("User-Agent"))
response := grafanaComPluginVersionMeta{
PluginID: "test-plugin",
Version: "1.0.0",
JSON: expectedMeta,
Description: "Test description",
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
require.NoError(t, json.NewEncoder(w).Encode(response))
}))
defer server.Close()
provider := NewCloudProvider(server.URL + "/api/plugins")
result, err := provider.GetMeta(ctx, "test-plugin", "1.0.0")
require.NoError(t, err)
require.NotNil(t, result)
assert.Equal(t, expectedMeta, result.Meta)
assert.Equal(t, defaultCloudTTL, result.TTL)
})
t.Run("returns ErrMetaNotFound for 404 status", func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound)
}))
defer server.Close()
provider := NewCloudProvider(server.URL + "/api/plugins")
result, err := provider.GetMeta(ctx, "nonexistent-plugin", "1.0.0")
assert.Error(t, err)
assert.True(t, errors.Is(err, ErrMetaNotFound))
assert.Nil(t, result)
})
t.Run("returns error for non-200 status codes", func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
}))
defer server.Close()
provider := NewCloudProvider(server.URL + "/api/plugins")
result, err := provider.GetMeta(ctx, "test-plugin", "1.0.0")
assert.Error(t, err)
assert.Contains(t, err.Error(), "unexpected status code 500")
assert.Nil(t, result)
})
t.Run("returns error for invalid JSON response", func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("invalid json"))
}))
defer server.Close()
provider := NewCloudProvider(server.URL + "/api/plugins")
result, err := provider.GetMeta(ctx, "test-plugin", "1.0.0")
assert.Error(t, err)
assert.Contains(t, err.Error(), "failed to decode response")
assert.Nil(t, result)
})
t.Run("returns error for invalid API URL", func(t *testing.T) {
provider := NewCloudProvider("://invalid-url")
result, err := provider.GetMeta(ctx, "test-plugin", "1.0.0")
assert.Error(t, err)
assert.Contains(t, err.Error(), "invalid grafana.com API URL")
assert.Nil(t, result)
})
t.Run("uses custom TTL when provided", func(t *testing.T) {
customTTL := 2 * time.Hour
expectedMeta := pluginsv0alpha1.PluginMetaJSONData{
Id: "test-plugin",
Name: "Test Plugin",
Type: pluginsv0alpha1.PluginMetaJSONDataTypeDatasource,
}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
response := grafanaComPluginVersionMeta{
PluginID: "test-plugin",
Version: "1.0.0",
JSON: expectedMeta,
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
require.NoError(t, json.NewEncoder(w).Encode(response))
}))
defer server.Close()
provider := NewCloudProviderWithTTL(server.URL+"/api/plugins", customTTL)
result, err := provider.GetMeta(ctx, "test-plugin", "1.0.0")
require.NoError(t, err)
require.NotNil(t, result)
assert.Equal(t, customTTL, result.TTL)
})
t.Run("handles context cancellation", func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
time.Sleep(100 * time.Millisecond)
w.WriteHeader(http.StatusOK)
}))
defer server.Close()
ctx, cancel := context.WithCancel(context.Background())
cancel()
provider := NewCloudProvider(server.URL + "/api/plugins")
result, err := provider.GetMeta(ctx, "test-plugin", "1.0.0")
assert.Error(t, err)
assert.Nil(t, result)
})
}
func TestNewCloudProvider(t *testing.T) {
t.Run("creates provider with default TTL", func(t *testing.T) {
provider := NewCloudProvider("https://grafana.com/api/plugins")
assert.Equal(t, defaultCloudTTL, provider.ttl)
assert.NotNil(t, provider.httpClient)
assert.Equal(t, "https://grafana.com/api/plugins", provider.grafanaComAPIURL)
})
t.Run("uses default URL when empty", func(t *testing.T) {
provider := NewCloudProvider("")
assert.Equal(t, "https://grafana.com/api/plugins", provider.grafanaComAPIURL)
})
}
func TestNewCloudProviderWithTTL(t *testing.T) {
t.Run("creates provider with custom TTL", func(t *testing.T) {
customTTL := 2 * time.Hour
provider := NewCloudProviderWithTTL("https://grafana.com/api/plugins", customTTL)
assert.Equal(t, customTTL, provider.ttl)
})
t.Run("accepts zero TTL", func(t *testing.T) {
provider := NewCloudProviderWithTTL("https://grafana.com/api/plugins", 0)
assert.Equal(t, time.Duration(0), provider.ttl)
})
t.Run("uses default URL when empty", func(t *testing.T) {
provider := NewCloudProviderWithTTL("", defaultCloudTTL)
assert.Equal(t, "https://grafana.com/api/plugins", provider.grafanaComAPIURL)
})
}
+603
View File
@@ -0,0 +1,603 @@
package meta
import (
"context"
"encoding/json"
"errors"
"os"
"path/filepath"
"sync"
"time"
"github.com/grafana/grafana-app-sdk/logging"
pluginsv0alpha1 "github.com/grafana/grafana/apps/plugins/pkg/apis/plugins/v0alpha1"
"github.com/grafana/grafana/pkg/plugins"
"github.com/grafana/grafana/pkg/plugins/manager/sources"
)
const (
defaultCoreTTL = 24 * time.Hour
)
// CoreProvider retrieves plugin metadata for core plugins.
type CoreProvider struct {
mu sync.RWMutex
loadedPlugins map[string]pluginsv0alpha1.PluginMetaJSONData
initialized bool
ttl time.Duration
}
// NewCoreProvider creates a new CoreProvider for core plugins.
func NewCoreProvider() *CoreProvider {
return NewCoreProviderWithTTL(defaultCoreTTL)
}
// NewCoreProviderWithTTL creates a new CoreProvider with a custom TTL.
func NewCoreProviderWithTTL(ttl time.Duration) *CoreProvider {
return &CoreProvider{
loadedPlugins: make(map[string]pluginsv0alpha1.PluginMetaJSONData),
ttl: ttl,
}
}
// GetMeta retrieves plugin metadata for core plugins.
func (p *CoreProvider) GetMeta(ctx context.Context, pluginID, _ string) (*Result, error) {
// Check cache first
p.mu.RLock()
if meta, found := p.loadedPlugins[pluginID]; found {
p.mu.RUnlock()
return &Result{
Meta: meta,
TTL: p.ttl,
}, nil
}
p.mu.RUnlock()
// Initialize cache if not already done
p.mu.Lock()
defer p.mu.Unlock()
// Double-check after acquiring write lock
if meta, found := p.loadedPlugins[pluginID]; found {
return &Result{
Meta: meta,
TTL: p.ttl,
}, nil
}
if !p.initialized {
if err := p.loadPlugins(ctx); err != nil {
logging.DefaultLogger.Warn("CoreProvider: could not load core plugins, will return ErrMetaNotFound for all lookups", "error", err)
// Mark as initialized even on failure so we don't keep trying
p.initialized = true
return nil, ErrMetaNotFound
}
p.initialized = true
}
if meta, found := p.loadedPlugins[pluginID]; found {
return &Result{
Meta: meta,
TTL: p.ttl,
}, nil
}
return nil, ErrMetaNotFound
}
// loadPlugins discovers and caches all core plugins.
// Returns an error if the static root path cannot be found or if plugin discovery fails.
// This error will be handled gracefully by GetMeta, which will return ErrMetaNotFound
// to allow other providers to handle the request.
func (p *CoreProvider) loadPlugins(ctx context.Context) error {
var staticRootPath string
if wd, err := os.Getwd(); err == nil {
// Check if we're in the Grafana root
publicPath := filepath.Join(wd, "public", "app", "plugins")
if _, err = os.Stat(publicPath); err == nil {
staticRootPath = filepath.Join(wd, "public")
}
}
if staticRootPath == "" {
return errors.New("could not find Grafana static root path")
}
datasourcePath := filepath.Join(staticRootPath, "app", "plugins", "datasource")
panelPath := filepath.Join(staticRootPath, "app", "plugins", "panel")
src := sources.NewLocalSource(plugins.ClassCore, []string{datasourcePath, panelPath})
ps, err := src.Discover(ctx)
if err != nil {
return err
}
if len(ps) == 0 {
logging.DefaultLogger.Warn("CoreProvider: no core plugins found during discovery")
return nil
}
for _, bundle := range ps {
meta := jsonDataToPluginMetaJSONData(bundle.Primary.JSONData)
p.loadedPlugins[bundle.Primary.JSONData.ID] = meta
}
return nil
}
// jsonDataToPluginMetaJSONData converts a plugins.JSONData to a pluginsv0alpha1.PluginMetaJSONData.
// nolint:gocyclo
func jsonDataToPluginMetaJSONData(jsonData plugins.JSONData) pluginsv0alpha1.PluginMetaJSONData {
meta := pluginsv0alpha1.PluginMetaJSONData{
Id: jsonData.ID,
Name: jsonData.Name,
}
// Map plugin type
switch jsonData.Type {
case plugins.TypeApp:
meta.Type = pluginsv0alpha1.PluginMetaJSONDataTypeApp
case plugins.TypeDataSource:
meta.Type = pluginsv0alpha1.PluginMetaJSONDataTypeDatasource
case plugins.TypePanel:
meta.Type = pluginsv0alpha1.PluginMetaJSONDataTypePanel
case plugins.TypeRenderer:
meta.Type = pluginsv0alpha1.PluginMetaJSONDataTypeRenderer
}
// Map Info
meta.Info = pluginsv0alpha1.PluginMetaInfo{
Keywords: jsonData.Info.Keywords,
Logos: pluginsv0alpha1.PluginMetaV0alpha1InfoLogos{
Small: jsonData.Info.Logos.Small,
Large: jsonData.Info.Logos.Large,
},
Updated: jsonData.Info.Updated,
Version: jsonData.Info.Version,
}
if jsonData.Info.Description != "" {
meta.Info.Description = &jsonData.Info.Description
}
if jsonData.Info.Author.Name != "" || jsonData.Info.Author.URL != "" {
author := &pluginsv0alpha1.PluginMetaV0alpha1InfoAuthor{}
if jsonData.Info.Author.Name != "" {
author.Name = &jsonData.Info.Author.Name
}
if jsonData.Info.Author.URL != "" {
author.Url = &jsonData.Info.Author.URL
}
meta.Info.Author = author
}
if len(jsonData.Info.Links) > 0 {
meta.Info.Links = make([]pluginsv0alpha1.PluginMetaV0alpha1InfoLinks, 0, len(jsonData.Info.Links))
for _, link := range jsonData.Info.Links {
v0Link := pluginsv0alpha1.PluginMetaV0alpha1InfoLinks{}
if link.Name != "" {
v0Link.Name = &link.Name
}
if link.URL != "" {
v0Link.Url = &link.URL
}
meta.Info.Links = append(meta.Info.Links, v0Link)
}
}
if len(jsonData.Info.Screenshots) > 0 {
meta.Info.Screenshots = make([]pluginsv0alpha1.PluginMetaV0alpha1InfoScreenshots, 0, len(jsonData.Info.Screenshots))
for _, screenshot := range jsonData.Info.Screenshots {
v0Screenshot := pluginsv0alpha1.PluginMetaV0alpha1InfoScreenshots{}
if screenshot.Name != "" {
v0Screenshot.Name = &screenshot.Name
}
if screenshot.Path != "" {
v0Screenshot.Path = &screenshot.Path
}
meta.Info.Screenshots = append(meta.Info.Screenshots, v0Screenshot)
}
}
// Map Dependencies
meta.Dependencies = pluginsv0alpha1.PluginMetaDependencies{
GrafanaDependency: jsonData.Dependencies.GrafanaDependency,
}
if jsonData.Dependencies.GrafanaVersion != "" {
meta.Dependencies.GrafanaVersion = &jsonData.Dependencies.GrafanaVersion
}
if len(jsonData.Dependencies.Plugins) > 0 {
meta.Dependencies.Plugins = make([]pluginsv0alpha1.PluginMetaV0alpha1DependenciesPlugins, 0, len(jsonData.Dependencies.Plugins))
for _, dep := range jsonData.Dependencies.Plugins {
var depType pluginsv0alpha1.PluginMetaV0alpha1DependenciesPluginsType
switch dep.Type {
case "app":
depType = pluginsv0alpha1.PluginMetaV0alpha1DependenciesPluginsTypeApp
case "datasource":
depType = pluginsv0alpha1.PluginMetaV0alpha1DependenciesPluginsTypeDatasource
case "panel":
depType = pluginsv0alpha1.PluginMetaV0alpha1DependenciesPluginsTypePanel
}
meta.Dependencies.Plugins = append(meta.Dependencies.Plugins, pluginsv0alpha1.PluginMetaV0alpha1DependenciesPlugins{
Id: dep.ID,
Type: depType,
Name: dep.Name,
})
}
}
if len(jsonData.Dependencies.Extensions.ExposedComponents) > 0 {
meta.Dependencies.Extensions = &pluginsv0alpha1.PluginMetaV0alpha1DependenciesExtensions{
ExposedComponents: jsonData.Dependencies.Extensions.ExposedComponents,
}
}
// Map optional boolean fields
if jsonData.Alerting {
meta.Alerting = &jsonData.Alerting
}
if jsonData.Annotations {
meta.Annotations = &jsonData.Annotations
}
if jsonData.AutoEnabled {
meta.AutoEnabled = &jsonData.AutoEnabled
}
if jsonData.Backend {
meta.Backend = &jsonData.Backend
}
if jsonData.BuiltIn {
meta.BuiltIn = &jsonData.BuiltIn
}
if jsonData.HideFromList {
meta.HideFromList = &jsonData.HideFromList
}
if jsonData.Logs {
meta.Logs = &jsonData.Logs
}
if jsonData.Metrics {
meta.Metrics = &jsonData.Metrics
}
if jsonData.MultiValueFilterOperators {
meta.MultiValueFilterOperators = &jsonData.MultiValueFilterOperators
}
if jsonData.Preload {
meta.Preload = &jsonData.Preload
}
if jsonData.SkipDataQuery {
meta.SkipDataQuery = &jsonData.SkipDataQuery
}
if jsonData.Streaming {
meta.Streaming = &jsonData.Streaming
}
if jsonData.Tracing {
meta.Tracing = &jsonData.Tracing
}
// Map category
if jsonData.Category != "" {
var category pluginsv0alpha1.PluginMetaJSONDataCategory
switch jsonData.Category {
case "tsdb":
category = pluginsv0alpha1.PluginMetaJSONDataCategoryTsdb
case "logging":
category = pluginsv0alpha1.PluginMetaJSONDataCategoryLogging
case "cloud":
category = pluginsv0alpha1.PluginMetaJSONDataCategoryCloud
case "tracing":
category = pluginsv0alpha1.PluginMetaJSONDataCategoryTracing
case "profiling":
category = pluginsv0alpha1.PluginMetaJSONDataCategoryProfiling
case "sql":
category = pluginsv0alpha1.PluginMetaJSONDataCategorySql
case "enterprise":
category = pluginsv0alpha1.PluginMetaJSONDataCategoryEnterprise
case "iot":
category = pluginsv0alpha1.PluginMetaJSONDataCategoryIot
case "other":
category = pluginsv0alpha1.PluginMetaJSONDataCategoryOther
default:
category = pluginsv0alpha1.PluginMetaJSONDataCategoryOther
}
meta.Category = &category
}
// Map state
if jsonData.State != "" {
var state pluginsv0alpha1.PluginMetaJSONDataState
switch jsonData.State {
case plugins.ReleaseStateAlpha:
state = pluginsv0alpha1.PluginMetaJSONDataStateAlpha
case plugins.ReleaseStateBeta:
state = pluginsv0alpha1.PluginMetaJSONDataStateBeta
default:
}
if state != "" {
meta.State = &state
}
}
// Map executable
if jsonData.Executable != "" {
meta.Executable = &jsonData.Executable
}
// Map QueryOptions
if len(jsonData.QueryOptions) > 0 {
queryOptions := &pluginsv0alpha1.PluginMetaQueryOptions{}
if val, ok := jsonData.QueryOptions["maxDataPoints"]; ok {
queryOptions.MaxDataPoints = &val
}
if val, ok := jsonData.QueryOptions["minInterval"]; ok {
queryOptions.MinInterval = &val
}
if val, ok := jsonData.QueryOptions["cacheTimeout"]; ok {
queryOptions.CacheTimeout = &val
}
meta.QueryOptions = queryOptions
}
// Map Includes
if len(jsonData.Includes) > 0 {
meta.Includes = make([]pluginsv0alpha1.PluginMetaInclude, 0, len(jsonData.Includes))
for _, include := range jsonData.Includes {
v0Include := pluginsv0alpha1.PluginMetaInclude{}
if include.UID != "" {
v0Include.Uid = &include.UID
}
if include.Type != "" {
var includeType pluginsv0alpha1.PluginMetaIncludeType
switch include.Type {
case "dashboard":
includeType = pluginsv0alpha1.PluginMetaIncludeTypeDashboard
case "page":
includeType = pluginsv0alpha1.PluginMetaIncludeTypePage
case "panel":
includeType = pluginsv0alpha1.PluginMetaIncludeTypePanel
case "datasource":
includeType = pluginsv0alpha1.PluginMetaIncludeTypeDatasource
}
v0Include.Type = &includeType
}
if include.Name != "" {
v0Include.Name = &include.Name
}
if include.Component != "" {
v0Include.Component = &include.Component
}
if include.Role != "" {
var role pluginsv0alpha1.PluginMetaIncludeRole
switch include.Role {
case "Admin":
role = pluginsv0alpha1.PluginMetaIncludeRoleAdmin
case "Editor":
role = pluginsv0alpha1.PluginMetaIncludeRoleEditor
case "Viewer":
role = pluginsv0alpha1.PluginMetaIncludeRoleViewer
}
v0Include.Role = &role
}
if include.Action != "" {
v0Include.Action = &include.Action
}
if include.Path != "" {
v0Include.Path = &include.Path
}
if include.AddToNav {
v0Include.AddToNav = &include.AddToNav
}
if include.DefaultNav {
v0Include.DefaultNav = &include.DefaultNav
}
if include.Icon != "" {
v0Include.Icon = &include.Icon
}
meta.Includes = append(meta.Includes, v0Include)
}
}
// Map Routes
if len(jsonData.Routes) > 0 {
meta.Routes = make([]pluginsv0alpha1.PluginMetaRoute, 0, len(jsonData.Routes))
for _, route := range jsonData.Routes {
v0Route := pluginsv0alpha1.PluginMetaRoute{}
if route.Path != "" {
v0Route.Path = &route.Path
}
if route.Method != "" {
v0Route.Method = &route.Method
}
if route.URL != "" {
v0Route.Url = &route.URL
}
if route.ReqRole != "" {
reqRole := string(route.ReqRole)
v0Route.ReqRole = &reqRole
}
if route.ReqAction != "" {
v0Route.ReqAction = &route.ReqAction
}
if len(route.Headers) > 0 {
headers := make([]string, 0, len(route.Headers))
for _, header := range route.Headers {
headers = append(headers, header.Name+": "+header.Content)
}
v0Route.Headers = headers
}
if len(route.URLParams) > 0 {
v0Route.UrlParams = make([]pluginsv0alpha1.PluginMetaV0alpha1RouteUrlParams, 0, len(route.URLParams))
for _, param := range route.URLParams {
v0Param := pluginsv0alpha1.PluginMetaV0alpha1RouteUrlParams{}
if param.Name != "" {
v0Param.Name = &param.Name
}
if param.Content != "" {
v0Param.Content = &param.Content
}
v0Route.UrlParams = append(v0Route.UrlParams, v0Param)
}
}
if route.TokenAuth != nil {
v0Route.TokenAuth = &pluginsv0alpha1.PluginMetaV0alpha1RouteTokenAuth{}
if route.TokenAuth.Url != "" {
v0Route.TokenAuth.Url = &route.TokenAuth.Url
}
if len(route.TokenAuth.Scopes) > 0 {
v0Route.TokenAuth.Scopes = route.TokenAuth.Scopes
}
if len(route.TokenAuth.Params) > 0 {
v0Route.TokenAuth.Params = make(map[string]interface{})
for k, v := range route.TokenAuth.Params {
v0Route.TokenAuth.Params[k] = v
}
}
}
if route.JwtTokenAuth != nil {
v0Route.JwtTokenAuth = &pluginsv0alpha1.PluginMetaV0alpha1RouteJwtTokenAuth{}
if route.JwtTokenAuth.Url != "" {
v0Route.JwtTokenAuth.Url = &route.JwtTokenAuth.Url
}
if len(route.JwtTokenAuth.Scopes) > 0 {
v0Route.JwtTokenAuth.Scopes = route.JwtTokenAuth.Scopes
}
if len(route.JwtTokenAuth.Params) > 0 {
v0Route.JwtTokenAuth.Params = make(map[string]interface{})
for k, v := range route.JwtTokenAuth.Params {
v0Route.JwtTokenAuth.Params[k] = v
}
}
}
if len(route.Body) > 0 {
var bodyMap map[string]interface{}
if err := json.Unmarshal(route.Body, &bodyMap); err == nil {
v0Route.Body = bodyMap
}
}
meta.Routes = append(meta.Routes, v0Route)
}
}
// Map Extensions
if len(jsonData.Extensions.AddedLinks) > 0 || len(jsonData.Extensions.AddedComponents) > 0 ||
len(jsonData.Extensions.ExposedComponents) > 0 || len(jsonData.Extensions.ExtensionPoints) > 0 {
extensions := &pluginsv0alpha1.PluginMetaExtensions{}
if len(jsonData.Extensions.AddedLinks) > 0 {
extensions.AddedLinks = make([]pluginsv0alpha1.PluginMetaV0alpha1ExtensionsAddedLinks, 0, len(jsonData.Extensions.AddedLinks))
for _, link := range jsonData.Extensions.AddedLinks {
v0Link := pluginsv0alpha1.PluginMetaV0alpha1ExtensionsAddedLinks{
Targets: link.Targets,
Title: link.Title,
}
if link.Description != "" {
v0Link.Description = &link.Description
}
extensions.AddedLinks = append(extensions.AddedLinks, v0Link)
}
}
if len(jsonData.Extensions.AddedComponents) > 0 {
extensions.AddedComponents = make([]pluginsv0alpha1.PluginMetaV0alpha1ExtensionsAddedComponents, 0, len(jsonData.Extensions.AddedComponents))
for _, comp := range jsonData.Extensions.AddedComponents {
v0Comp := pluginsv0alpha1.PluginMetaV0alpha1ExtensionsAddedComponents{
Targets: comp.Targets,
Title: comp.Title,
}
if comp.Description != "" {
v0Comp.Description = &comp.Description
}
extensions.AddedComponents = append(extensions.AddedComponents, v0Comp)
}
}
if len(jsonData.Extensions.ExposedComponents) > 0 {
extensions.ExposedComponents = make([]pluginsv0alpha1.PluginMetaV0alpha1ExtensionsExposedComponents, 0, len(jsonData.Extensions.ExposedComponents))
for _, comp := range jsonData.Extensions.ExposedComponents {
v0Comp := pluginsv0alpha1.PluginMetaV0alpha1ExtensionsExposedComponents{
Id: comp.Id,
}
if comp.Title != "" {
v0Comp.Title = &comp.Title
}
if comp.Description != "" {
v0Comp.Description = &comp.Description
}
extensions.ExposedComponents = append(extensions.ExposedComponents, v0Comp)
}
}
if len(jsonData.Extensions.ExtensionPoints) > 0 {
extensions.ExtensionPoints = make([]pluginsv0alpha1.PluginMetaV0alpha1ExtensionsExtensionPoints, 0, len(jsonData.Extensions.ExtensionPoints))
for _, point := range jsonData.Extensions.ExtensionPoints {
v0Point := pluginsv0alpha1.PluginMetaV0alpha1ExtensionsExtensionPoints{
Id: point.Id,
}
if point.Title != "" {
v0Point.Title = &point.Title
}
if point.Description != "" {
v0Point.Description = &point.Description
}
extensions.ExtensionPoints = append(extensions.ExtensionPoints, v0Point)
}
}
meta.Extensions = extensions
}
// Map Roles
if len(jsonData.Roles) > 0 {
meta.Roles = make([]pluginsv0alpha1.PluginMetaRole, 0, len(jsonData.Roles))
for _, role := range jsonData.Roles {
v0Role := pluginsv0alpha1.PluginMetaRole{
Grants: role.Grants,
}
if role.Role.Name != "" || role.Role.Description != "" || len(role.Role.Permissions) > 0 {
v0RoleRole := &pluginsv0alpha1.PluginMetaV0alpha1RoleRole{}
if role.Role.Name != "" {
v0RoleRole.Name = &role.Role.Name
}
if role.Role.Description != "" {
v0RoleRole.Description = &role.Role.Description
}
if len(role.Role.Permissions) > 0 {
v0RoleRole.Permissions = make([]pluginsv0alpha1.PluginMetaV0alpha1RoleRolePermissions, 0, len(role.Role.Permissions))
for _, perm := range role.Role.Permissions {
v0Perm := pluginsv0alpha1.PluginMetaV0alpha1RoleRolePermissions{}
if perm.Action != "" {
v0Perm.Action = &perm.Action
}
if perm.Scope != "" {
v0Perm.Scope = &perm.Scope
}
v0RoleRole.Permissions = append(v0RoleRole.Permissions, v0Perm)
}
}
v0Role.Role = v0RoleRole
}
meta.Roles = append(meta.Roles, v0Role)
}
}
// Map IAM
if jsonData.IAM != nil && len(jsonData.IAM.Permissions) > 0 {
iam := &pluginsv0alpha1.PluginMetaIAM{
Permissions: make([]pluginsv0alpha1.PluginMetaV0alpha1IAMPermissions, 0, len(jsonData.IAM.Permissions)),
}
for _, perm := range jsonData.IAM.Permissions {
v0Perm := pluginsv0alpha1.PluginMetaV0alpha1IAMPermissions{}
if perm.Action != "" {
v0Perm.Action = &perm.Action
}
if perm.Scope != "" {
v0Perm.Scope = &perm.Scope
}
iam.Permissions = append(iam.Permissions, v0Perm)
}
meta.Iam = iam
}
return meta
}
+302
View File
@@ -0,0 +1,302 @@
package meta
import (
"context"
"errors"
"os"
"path/filepath"
"runtime"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
pluginsv0alpha1 "github.com/grafana/grafana/apps/plugins/pkg/apis/plugins/v0alpha1"
"github.com/grafana/grafana/pkg/plugins"
)
func TestCoreProvider_GetMeta(t *testing.T) {
ctx := context.Background()
t.Run("returns cached plugin when available", func(t *testing.T) {
provider := NewCoreProvider()
expectedMeta := pluginsv0alpha1.PluginMetaJSONData{
Id: "test-plugin",
Name: "Test Plugin",
Type: pluginsv0alpha1.PluginMetaJSONDataTypeDatasource,
}
provider.mu.Lock()
provider.loadedPlugins["test-plugin"] = expectedMeta
provider.initialized = true
provider.mu.Unlock()
result, err := provider.GetMeta(ctx, "test-plugin", "1.0.0")
require.NoError(t, err)
require.NotNil(t, result)
assert.Equal(t, expectedMeta, result.Meta)
assert.Equal(t, defaultCoreTTL, result.TTL)
})
t.Run("returns ErrMetaNotFound for non-existent plugin", func(t *testing.T) {
provider := NewCoreProvider()
provider.mu.Lock()
provider.initialized = true
provider.mu.Unlock()
result, err := provider.GetMeta(ctx, "nonexistent-plugin", "1.0.0")
assert.Error(t, err)
assert.True(t, errors.Is(err, ErrMetaNotFound))
assert.Nil(t, result)
})
t.Run("ignores version parameter", func(t *testing.T) {
provider := NewCoreProvider()
expectedMeta := pluginsv0alpha1.PluginMetaJSONData{
Id: "test-plugin",
Name: "Test Plugin",
Type: pluginsv0alpha1.PluginMetaJSONDataTypeDatasource,
}
provider.mu.Lock()
provider.loadedPlugins["test-plugin"] = expectedMeta
provider.initialized = true
provider.mu.Unlock()
result1, err1 := provider.GetMeta(ctx, "test-plugin", "1.0.0")
result2, err2 := provider.GetMeta(ctx, "test-plugin", "2.0.0")
require.NoError(t, err1)
require.NoError(t, err2)
assert.Equal(t, result1.Meta, result2.Meta)
})
t.Run("uses custom TTL when provided", func(t *testing.T) {
customTTL := 2 * time.Hour
provider := NewCoreProviderWithTTL(customTTL)
expectedMeta := pluginsv0alpha1.PluginMetaJSONData{
Id: "test-plugin",
Name: "Test Plugin",
Type: pluginsv0alpha1.PluginMetaJSONDataTypeDatasource,
}
provider.mu.Lock()
provider.loadedPlugins["test-plugin"] = expectedMeta
provider.initialized = true
provider.mu.Unlock()
result, err := provider.GetMeta(ctx, "test-plugin", "1.0.0")
require.NoError(t, err)
require.NotNil(t, result)
assert.Equal(t, customTTL, result.TTL)
})
t.Run("gracefully handles initialization failure and returns ErrMetaNotFound", func(t *testing.T) {
tempDir := t.TempDir()
oldWd, err := os.Getwd()
require.NoError(t, err)
defer func() {
_ = os.Chdir(oldWd)
}()
require.NoError(t, os.Chdir(tempDir))
provider := NewCoreProvider()
result, err := provider.GetMeta(ctx, "any-plugin", "1.0.0")
assert.Error(t, err)
assert.True(t, errors.Is(err, ErrMetaNotFound))
assert.Nil(t, result)
initialized := provider.initialized
assert.True(t, initialized, "provider should be marked as initialized even after failure")
})
}
func TestCoreProvider_loadPlugins(t *testing.T) {
ctx := context.Background()
t.Run("loads all core plugins", func(t *testing.T) {
_, filename, _, _ := runtime.Caller(0)
testDir := filepath.Dir(filename)
grafanaRoot := filepath.Join(testDir, "..", "..", "..", "..", "..")
grafanaRoot, err := filepath.Abs(grafanaRoot)
require.NoError(t, err)
publicPath := filepath.Join(grafanaRoot, "public", "app", "plugins")
if _, err = os.Stat(publicPath); err != nil {
t.Skipf("Grafana root not found at %s, skipping integration test: %v", publicPath, err)
}
require.NoError(t, os.Chdir(grafanaRoot))
provider := NewCoreProvider()
err = provider.loadPlugins(ctx)
require.NoError(t, err)
assert.Len(t, provider.loadedPlugins, 53)
})
t.Run("returns error when static root path not found", func(t *testing.T) {
tempDir := t.TempDir()
oldWd, err := os.Getwd()
require.NoError(t, err)
defer func() {
_ = os.Chdir(oldWd)
}()
require.NoError(t, os.Chdir(tempDir))
provider := NewCoreProvider()
err = provider.loadPlugins(ctx)
assert.Error(t, err)
assert.Contains(t, err.Error(), "could not find Grafana static root path")
})
t.Run("returns no error when no plugins found", func(t *testing.T) {
tempDir := t.TempDir()
publicPath := filepath.Join(tempDir, "public", "app", "plugins")
require.NoError(t, os.MkdirAll(publicPath, 0750))
oldWd, err := os.Getwd()
require.NoError(t, err)
defer func() {
_ = os.Chdir(oldWd)
}()
require.NoError(t, os.Chdir(tempDir))
provider := NewCoreProvider()
err = provider.loadPlugins(ctx)
assert.NoError(t, err)
})
t.Run("successfully loads plugins when structure exists", func(t *testing.T) {
tempDir := t.TempDir()
publicPath := filepath.Join(tempDir, "public", "app", "plugins")
datasourcePath := filepath.Join(publicPath, "datasource")
panelPath := filepath.Join(publicPath, "panel")
require.NoError(t, os.MkdirAll(datasourcePath, 0750))
require.NoError(t, os.MkdirAll(panelPath, 0750))
pluginDir := filepath.Join(datasourcePath, "test-datasource")
require.NoError(t, os.MkdirAll(pluginDir, 0750))
pluginJSON := `{
"id": "test-datasource",
"name": "Test Datasource",
"type": "datasource",
"info": {
"version": "1.0.0",
"description": "Test description"
}
}`
require.NoError(t, os.WriteFile(filepath.Join(pluginDir, "plugin.json"), []byte(pluginJSON), 0644))
oldWd, err := os.Getwd()
require.NoError(t, err)
defer func() {
_ = os.Chdir(oldWd)
}()
require.NoError(t, os.Chdir(tempDir))
provider := NewCoreProvider()
err = provider.loadPlugins(ctx)
if err != nil {
require.NoError(t, err)
}
provider.mu.RLock()
loaded := len(provider.loadedPlugins) > 0
provider.mu.RUnlock()
if loaded {
result, err := provider.GetMeta(ctx, "test-datasource", "1.0.0")
require.NoError(t, err)
assert.Equal(t, "test-datasource", result.Meta.Id)
assert.Equal(t, "Test Datasource", result.Meta.Name)
}
})
}
func TestNewCoreProvider(t *testing.T) {
t.Run("creates provider with default TTL", func(t *testing.T) {
provider := NewCoreProvider()
assert.Equal(t, defaultCoreTTL, provider.ttl)
assert.NotNil(t, provider.loadedPlugins)
assert.False(t, provider.initialized)
})
}
func TestNewCoreProviderWithTTL(t *testing.T) {
t.Run("creates provider with custom TTL", func(t *testing.T) {
customTTL := 2 * time.Hour
provider := NewCoreProviderWithTTL(customTTL)
assert.Equal(t, customTTL, provider.ttl)
})
t.Run("accepts zero TTL", func(t *testing.T) {
provider := NewCoreProviderWithTTL(0)
assert.Equal(t, time.Duration(0), provider.ttl)
})
}
func TestJsonDataToMeta(t *testing.T) {
t.Run("converts basic plugin JSON data", func(t *testing.T) {
jsonData := plugins.JSONData{
ID: "test-plugin",
Name: "Test Plugin",
Type: plugins.TypeDataSource,
Info: plugins.Info{
Version: "1.0.0",
Description: "Test description",
Keywords: []string{"test", "plugin"},
Logos: plugins.Logos{
Small: "small.png",
Large: "large.png",
},
},
}
meta := jsonDataToPluginMetaJSONData(jsonData)
assert.Equal(t, "test-plugin", meta.Id)
assert.Equal(t, "Test Plugin", meta.Name)
assert.Equal(t, pluginsv0alpha1.PluginMetaJSONDataTypeDatasource, meta.Type)
assert.Equal(t, "1.0.0", meta.Info.Version)
assert.Equal(t, "Test description", *meta.Info.Description)
assert.Equal(t, []string{"test", "plugin"}, meta.Info.Keywords)
assert.Equal(t, "small.png", meta.Info.Logos.Small)
assert.Equal(t, "large.png", meta.Info.Logos.Large)
})
t.Run("handles optional fields", func(t *testing.T) {
jsonData := plugins.JSONData{
ID: "test-plugin",
Name: "Test Plugin",
Type: plugins.TypePanel,
Info: plugins.Info{
Version: "1.0.0",
},
}
meta := jsonDataToPluginMetaJSONData(jsonData)
assert.Nil(t, meta.Info.Description)
assert.Nil(t, meta.Info.Author)
assert.Empty(t, meta.Info.Keywords)
})
}
+131
View File
@@ -0,0 +1,131 @@
package meta
import (
"context"
"errors"
"fmt"
"sync"
"time"
pluginsv0alpha1 "github.com/grafana/grafana/apps/plugins/pkg/apis/plugins/v0alpha1"
)
const (
defaultCleanupInterval = 10 * time.Minute
)
// cachedMeta represents a cached metadata entry with expiration time
type cachedMeta struct {
meta pluginsv0alpha1.PluginMetaJSONData
ttl time.Duration
expiresAt time.Time
}
// ProviderManager searches multiple providers for Plugin Meta in order until one succeeds, and caches
// results with per-provider TTLs.
// It implements app.Runnable to manage the cleanup goroutine lifecycle.
type ProviderManager struct {
providers []Provider
cache map[string]*cachedMeta
cacheMu sync.RWMutex
}
// NewProviderManager creates a new ProviderManager that chains the given providers
// and caches results with per-provider TTLs.
func NewProviderManager(providers ...Provider) *ProviderManager {
if len(providers) == 0 {
panic("ProviderManager requires at least one provider")
}
return &ProviderManager{
providers: providers,
cache: make(map[string]*cachedMeta),
}
}
// Run implements app.Runnable. It runs the cleanup loop until the context is cancelled.
// This method blocks until the context is cancelled (when the app shuts down).
func (pm *ProviderManager) Run(ctx context.Context) error {
ticker := time.NewTicker(defaultCleanupInterval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return nil
case <-ticker.C:
pm.cleanupExpired()
}
}
}
// GetMeta tries each provider in order until one succeeds, using cache when available.
// Returns ErrMetaNotFound only if all providers return ErrMetaNotFound.
// Otherwise, returns the last non-ErrMetaNotFound error if all providers fail.
func (pm *ProviderManager) GetMeta(ctx context.Context, pluginID, version string) (*Result, error) {
cacheKey := pm.cacheKey(pluginID, version)
// Check cache first
pm.cacheMu.RLock()
cached, exists := pm.cache[cacheKey]
pm.cacheMu.RUnlock()
if exists && time.Now().Before(cached.expiresAt) {
return &Result{
Meta: cached.meta,
TTL: cached.ttl,
}, nil
}
// Try each provider in order until one succeeds
var lastErr error
for _, provider := range pm.providers {
result, err := provider.GetMeta(ctx, pluginID, version)
if err == nil {
// Don't cache results with a zero TTL
if result.TTL == 0 {
continue
}
pm.cacheMu.Lock()
pm.cache[cacheKey] = &cachedMeta{
meta: result.Meta,
ttl: result.TTL,
expiresAt: time.Now().Add(result.TTL),
}
pm.cacheMu.Unlock()
return result, nil
}
// If not found, try next provider
if errors.Is(err, ErrMetaNotFound) {
continue
}
lastErr = err
}
if lastErr != nil {
return nil, fmt.Errorf("failed to fetch plugin metadata from any provider: %w", lastErr)
}
return nil, ErrMetaNotFound
}
// cleanupExpired removes expired entries from the cache.
func (pm *ProviderManager) cleanupExpired() {
now := time.Now()
pm.cacheMu.Lock()
defer pm.cacheMu.Unlock()
for key, entry := range pm.cache {
if now.After(entry.expiresAt) {
delete(pm.cache, key)
}
}
}
func (pm *ProviderManager) cacheKey(pluginID, version string) string {
return fmt.Sprintf("%s:%s", pluginID, version)
}
+434
View File
@@ -0,0 +1,434 @@
package meta
import (
"context"
"errors"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
pluginsv0alpha1 "github.com/grafana/grafana/apps/plugins/pkg/apis/plugins/v0alpha1"
)
func TestNewProviderManager(t *testing.T) {
t.Run("panics with no providers", func(t *testing.T) {
assert.Panics(t, func() {
NewProviderManager()
})
})
t.Run("creates manager with providers", func(t *testing.T) {
provider1 := &mockProvider{}
provider2 := &mockProvider{}
pm := NewProviderManager(provider1, provider2)
require.NotNil(t, pm)
assert.Len(t, pm.providers, 2)
assert.NotNil(t, pm.cache)
})
}
func TestProviderManager_GetMeta(t *testing.T) {
ctx := context.Background()
t.Run("returns cached result when available and not expired", func(t *testing.T) {
cachedMeta := pluginsv0alpha1.PluginMetaJSONData{
Id: "test-plugin",
Name: "Test Plugin",
Type: pluginsv0alpha1.PluginMetaJSONDataTypeDatasource,
}
provider := &mockProvider{
getMetaFunc: func(ctx context.Context, pluginID, version string) (*Result, error) {
return &Result{
Meta: cachedMeta,
TTL: time.Hour,
}, nil
},
}
pm := NewProviderManager(provider)
result1, err := pm.GetMeta(ctx, "test-plugin", "1.0.0")
require.NoError(t, err)
require.NotNil(t, result1)
assert.Equal(t, cachedMeta, result1.Meta)
assert.Equal(t, time.Hour, result1.TTL)
provider.getMetaFunc = func(ctx context.Context, pluginID, version string) (*Result, error) {
return &Result{
Meta: pluginsv0alpha1.PluginMetaJSONData{Id: "different"},
TTL: time.Hour,
}, nil
}
result2, err := pm.GetMeta(ctx, "test-plugin", "1.0.0")
require.NoError(t, err)
require.NotNil(t, result2)
assert.Equal(t, cachedMeta, result2.Meta)
assert.Equal(t, time.Hour, result2.TTL)
})
t.Run("fetches from provider when not cached", func(t *testing.T) {
expectedMeta := pluginsv0alpha1.PluginMetaJSONData{
Id: "test-plugin",
Name: "Test Plugin",
Type: pluginsv0alpha1.PluginMetaJSONDataTypeDatasource,
}
expectedTTL := 2 * time.Hour
provider := &mockProvider{
getMetaFunc: func(ctx context.Context, pluginID, version string) (*Result, error) {
return &Result{
Meta: expectedMeta,
TTL: expectedTTL,
}, nil
},
}
pm := NewProviderManager(provider)
result, err := pm.GetMeta(ctx, "test-plugin", "1.0.0")
require.NoError(t, err)
require.NotNil(t, result)
assert.Equal(t, expectedMeta, result.Meta)
assert.Equal(t, expectedTTL, result.TTL)
pm.cacheMu.RLock()
cached, exists := pm.cache["test-plugin:1.0.0"]
pm.cacheMu.RUnlock()
assert.True(t, exists)
assert.Equal(t, expectedMeta, cached.meta)
assert.Equal(t, expectedTTL, cached.ttl)
})
t.Run("does not cache result with zero TTL and tries next provider", func(t *testing.T) {
zeroTTLMeta := pluginsv0alpha1.PluginMetaJSONData{
Id: "test-plugin",
Name: "Zero TTL Plugin",
Type: pluginsv0alpha1.PluginMetaJSONDataTypeDatasource,
}
expectedMeta := pluginsv0alpha1.PluginMetaJSONData{
Id: "test-plugin",
Name: "Test Plugin",
Type: pluginsv0alpha1.PluginMetaJSONDataTypeDatasource,
}
provider1 := &mockProvider{
getMetaFunc: func(ctx context.Context, pluginID, version string) (*Result, error) {
return &Result{
Meta: zeroTTLMeta,
TTL: 0,
}, nil
},
}
provider2 := &mockProvider{
getMetaFunc: func(ctx context.Context, pluginID, version string) (*Result, error) {
return &Result{
Meta: expectedMeta,
TTL: time.Hour,
}, nil
},
}
pm := NewProviderManager(provider1, provider2)
result, err := pm.GetMeta(ctx, "test-plugin", "1.0.0")
require.NoError(t, err)
require.NotNil(t, result)
assert.Equal(t, expectedMeta, result.Meta)
pm.cacheMu.RLock()
cached, exists := pm.cache["test-plugin:1.0.0"]
pm.cacheMu.RUnlock()
assert.True(t, exists)
assert.Equal(t, expectedMeta, cached.meta)
assert.Equal(t, time.Hour, cached.ttl)
})
t.Run("tries next provider when first returns ErrMetaNotFound", func(t *testing.T) {
expectedMeta := pluginsv0alpha1.PluginMetaJSONData{
Id: "test-plugin",
Name: "Test Plugin",
Type: pluginsv0alpha1.PluginMetaJSONDataTypeDatasource,
}
provider1 := &mockProvider{
getMetaFunc: func(ctx context.Context, pluginID, version string) (*Result, error) {
return nil, ErrMetaNotFound
},
}
provider2 := &mockProvider{
getMetaFunc: func(ctx context.Context, pluginID, version string) (*Result, error) {
return &Result{
Meta: expectedMeta,
TTL: time.Hour,
}, nil
},
}
pm := NewProviderManager(provider1, provider2)
result, err := pm.GetMeta(ctx, "test-plugin", "1.0.0")
require.NoError(t, err)
require.NotNil(t, result)
assert.Equal(t, expectedMeta, result.Meta)
})
t.Run("returns ErrMetaNotFound when all providers return ErrMetaNotFound", func(t *testing.T) {
provider1 := &mockProvider{
getMetaFunc: func(ctx context.Context, pluginID, version string) (*Result, error) {
return nil, ErrMetaNotFound
},
}
provider2 := &mockProvider{
getMetaFunc: func(ctx context.Context, pluginID, version string) (*Result, error) {
return nil, ErrMetaNotFound
},
}
pm := NewProviderManager(provider1, provider2)
result, err := pm.GetMeta(ctx, "test-plugin", "1.0.0")
assert.Error(t, err)
assert.True(t, errors.Is(err, ErrMetaNotFound))
assert.Nil(t, result)
})
t.Run("returns error when provider returns non-ErrMetaNotFound error", func(t *testing.T) {
expectedErr := errors.New("network error")
provider1 := &mockProvider{
getMetaFunc: func(ctx context.Context, pluginID, version string) (*Result, error) {
return nil, expectedErr
},
}
provider2 := &mockProvider{
getMetaFunc: func(ctx context.Context, pluginID, version string) (*Result, error) {
return nil, ErrMetaNotFound
},
}
pm := NewProviderManager(provider1, provider2)
result, err := pm.GetMeta(ctx, "test-plugin", "1.0.0")
assert.Error(t, err)
assert.Contains(t, err.Error(), "failed to fetch plugin metadata from any provider")
assert.ErrorIs(t, err, expectedErr)
assert.Nil(t, result)
})
t.Run("skips expired cache entries", func(t *testing.T) {
expiredMeta := pluginsv0alpha1.PluginMetaJSONData{
Id: "test-plugin",
Name: "Expired Plugin",
Type: pluginsv0alpha1.PluginMetaJSONDataTypeDatasource,
}
expectedMeta := pluginsv0alpha1.PluginMetaJSONData{
Id: "test-plugin",
Name: "Test Plugin",
Type: pluginsv0alpha1.PluginMetaJSONDataTypeDatasource,
}
callCount := 0
provider := &mockProvider{
getMetaFunc: func(ctx context.Context, pluginID, version string) (*Result, error) {
callCount++
if callCount == 1 {
return &Result{
Meta: expiredMeta,
TTL: time.Nanosecond,
}, nil
}
return &Result{
Meta: expectedMeta,
TTL: time.Hour,
}, nil
},
}
pm := NewProviderManager(provider)
result1, err := pm.GetMeta(ctx, "test-plugin", "1.0.0")
require.NoError(t, err)
assert.Equal(t, expiredMeta, result1.Meta)
time.Sleep(2 * time.Nanosecond)
result2, err := pm.GetMeta(ctx, "test-plugin", "1.0.0")
require.NoError(t, err)
assert.Equal(t, expectedMeta, result2.Meta)
assert.Equal(t, 2, callCount)
})
t.Run("uses first successful provider", func(t *testing.T) {
expectedMeta1 := pluginsv0alpha1.PluginMetaJSONData{
Id: "test-plugin",
Name: "Provider 1 Plugin",
Type: pluginsv0alpha1.PluginMetaJSONDataTypeDatasource,
}
expectedMeta2 := pluginsv0alpha1.PluginMetaJSONData{
Id: "test-plugin",
Name: "Provider 2 Plugin",
Type: pluginsv0alpha1.PluginMetaJSONDataTypeDatasource,
}
provider1 := &mockProvider{
getMetaFunc: func(ctx context.Context, pluginID, version string) (*Result, error) {
return &Result{
Meta: expectedMeta1,
TTL: time.Hour,
}, nil
},
}
provider2 := &mockProvider{
getMetaFunc: func(ctx context.Context, pluginID, version string) (*Result, error) {
return &Result{
Meta: expectedMeta2,
TTL: time.Hour,
}, nil
},
}
pm := NewProviderManager(provider1, provider2)
result, err := pm.GetMeta(ctx, "test-plugin", "1.0.0")
require.NoError(t, err)
require.NotNil(t, result)
assert.Equal(t, expectedMeta1, result.Meta)
})
}
func TestProviderManager_Run(t *testing.T) {
t.Run("runs cleanup loop until context cancelled", func(t *testing.T) {
pm := NewProviderManager(&mockProvider{})
ctx, cancel := context.WithCancel(context.Background())
done := make(chan error, 1)
go func() {
done <- pm.Run(ctx)
}()
time.Sleep(10 * time.Millisecond)
cancel()
err := <-done
require.NoError(t, err)
})
}
func TestProviderManager_cleanupExpired(t *testing.T) {
t.Run("removes expired entries", func(t *testing.T) {
validMeta := pluginsv0alpha1.PluginMetaJSONData{Id: "valid"}
expiredMeta1 := pluginsv0alpha1.PluginMetaJSONData{Id: "expired1"}
expiredMeta2 := pluginsv0alpha1.PluginMetaJSONData{Id: "expired2"}
provider := &mockProvider{
getMetaFunc: func(ctx context.Context, pluginID, version string) (*Result, error) {
switch pluginID {
case "valid":
return &Result{Meta: validMeta, TTL: time.Hour}, nil
case "expired1":
return &Result{Meta: expiredMeta1, TTL: time.Nanosecond}, nil
case "expired2":
return &Result{Meta: expiredMeta2, TTL: time.Nanosecond}, nil
}
return nil, ErrMetaNotFound
},
}
pm := NewProviderManager(provider)
ctx := context.Background()
_, err := pm.GetMeta(ctx, "expired1", "1.0.0")
require.NoError(t, err)
_, err = pm.GetMeta(ctx, "expired2", "1.0.0")
require.NoError(t, err)
_, err = pm.GetMeta(ctx, "valid", "1.0.0")
require.NoError(t, err)
time.Sleep(2 * time.Nanosecond)
pm.cleanupExpired()
provider.getMetaFunc = func(ctx context.Context, pluginID, version string) (*Result, error) {
if pluginID == "valid" {
return &Result{Meta: validMeta, TTL: time.Hour}, nil
}
return nil, ErrMetaNotFound
}
result, err := pm.GetMeta(ctx, "expired1", "1.0.0")
assert.Error(t, err)
assert.Nil(t, result)
result, err = pm.GetMeta(ctx, "valid", "1.0.0")
require.NoError(t, err)
assert.Equal(t, validMeta, result.Meta)
})
t.Run("handles empty cache", func(t *testing.T) {
pm := NewProviderManager(&mockProvider{})
pm.cleanupExpired()
})
}
func TestProviderManager_cacheKey(t *testing.T) {
pm := NewProviderManager(&mockProvider{})
tests := []struct {
name string
pluginID string
version string
expected string
}{
{
name: "basic key",
pluginID: "test-plugin",
version: "1.0.0",
expected: "test-plugin:1.0.0",
},
{
name: "empty version",
pluginID: "test-plugin",
version: "",
expected: "test-plugin:",
},
{
name: "empty plugin ID",
pluginID: "",
version: "1.0.0",
expected: ":1.0.0",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
key := pm.cacheKey(tt.pluginID, tt.version)
assert.Equal(t, tt.expected, key)
})
}
}
type mockProvider struct {
getMetaFunc func(ctx context.Context, pluginID, version string) (*Result, error)
}
func (m *mockProvider) GetMeta(ctx context.Context, pluginID, version string) (*Result, error) {
if m.getMetaFunc != nil {
return m.getMetaFunc(ctx, pluginID, version)
}
return nil, ErrMetaNotFound
}
+27
View File
@@ -0,0 +1,27 @@
package meta
import (
"context"
"errors"
"time"
pluginsv0alpha1 "github.com/grafana/grafana/apps/plugins/pkg/apis/plugins/v0alpha1"
)
var (
ErrMetaNotFound = errors.New("not found")
)
// Result contains plugin metadata along with its recommended TTL.
type Result struct {
Meta pluginsv0alpha1.PluginMetaJSONData
TTL time.Duration
}
// Provider is used for retrieving plugin metadata.
type Provider interface {
// GetMeta retrieves plugin metadata for the given plugin ID and version.
// Returns the Result containing the PluginMetaJSONData and its recommended TTL.
// If the plugin is not found, returns ErrMetaNotFound.
GetMeta(ctx context.Context, pluginID, version string) (*Result, error)
}