Plugins: Add app (#108422)

This commit is contained in:
Todd Treece
2025-08-06 13:09:10 -04:00
committed by GitHub
parent 1f76765ed7
commit ce2697bb07
37 changed files with 2923 additions and 11 deletions
+6 -1
View File
@@ -14,6 +14,7 @@ import (
"github.com/grafana/grafana/pkg/registry/apps/alerting/notifications"
"github.com/grafana/grafana/pkg/registry/apps/investigations"
"github.com/grafana/grafana/pkg/registry/apps/playlist"
"github.com/grafana/grafana/pkg/registry/apps/plugins"
"github.com/grafana/grafana/pkg/registry/apps/shorturl"
"github.com/grafana/grafana/pkg/services/apiserver"
"github.com/grafana/grafana/pkg/services/apiserver/builder"
@@ -27,9 +28,13 @@ import (
func ProvideAppInstallers(
features featuremgmt.FeatureToggles,
playlistAppInstaller *playlist.PlaylistAppInstaller,
pluginsApplInstaller *plugins.PluginsAppInstaller,
shorturlAppInstaller *shorturl.ShortURLAppInstaller,
) []appsdkapiserver.AppInstaller {
installers := []appsdkapiserver.AppInstaller{playlistAppInstaller}
installers := []appsdkapiserver.AppInstaller{
playlistAppInstaller,
pluginsApplInstaller,
}
if features.IsEnabledGlobally(featuremgmt.FlagKubernetesShortURLs) {
installers = append(installers, shorturlAppInstaller)
}
+76
View File
@@ -0,0 +1,76 @@
package plugins
import (
"github.com/grafana/grafana-app-sdk/app"
appsdkapiserver "github.com/grafana/grafana-app-sdk/k8s/apiserver"
"github.com/grafana/grafana-app-sdk/simple"
"github.com/grafana/grafana/apps/plugins/pkg/apis"
"github.com/grafana/grafana/pkg/services/apiserver/appinstaller"
"github.com/grafana/grafana/pkg/services/apiserver/endpoints/request"
"github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/setting"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/apiserver/pkg/authorization/authorizer"
"k8s.io/apiserver/pkg/registry/generic"
"k8s.io/apiserver/pkg/registry/rest"
restclient "k8s.io/client-go/rest"
pluginsv0alpha1 "github.com/grafana/grafana/apps/plugins/pkg/apis/plugins/v0alpha1"
pluginsapp "github.com/grafana/grafana/apps/plugins/pkg/app"
)
var (
_ appsdkapiserver.AppInstaller = (*PluginsAppInstaller)(nil)
_ appinstaller.AuthorizerProvider = (*PluginsAppInstaller)(nil)
)
type PluginsAppInstaller struct {
appsdkapiserver.AppInstaller
cfg *setting.Cfg
}
func RegisterAppInstaller(
cfg *setting.Cfg,
features featuremgmt.FeatureToggles,
) (*PluginsAppInstaller, error) {
installer := &PluginsAppInstaller{
cfg: cfg,
}
specificConfig := any(nil)
provider := simple.NewAppProvider(apis.LocalManifest(), specificConfig, pluginsapp.New)
appConfig := app.Config{
KubeConfig: restclient.Config{}, // this will be overridden by the installer's InitializeApp method
ManifestData: *apis.LocalManifest().ManifestData,
SpecificConfig: specificConfig,
}
i, err := appsdkapiserver.NewDefaultAppInstaller(provider, appConfig, apis.ManifestGoTypeAssociator, apis.ManifestCustomRouteResponsesAssociator)
if err != nil {
return nil, err
}
installer.AppInstaller = i
return installer, nil
}
func (p *PluginsAppInstaller) InstallAPIs(
server appsdkapiserver.GenericAPIServer,
restOptsGetter generic.RESTOptionsGetter,
) error {
pluginMetaGVR := schema.GroupVersionResource{
Group: pluginsv0alpha1.GroupVersion.Group,
Version: pluginsv0alpha1.GroupVersion.Version,
Resource: pluginsv0alpha1.PluginMetaKind().Plural(),
}
replacedStorage := map[schema.GroupVersionResource]rest.Storage{
pluginMetaGVR: pluginsapp.NewPluginMetaStorage(request.GetNamespaceMapper(p.cfg)),
}
wrappedServer := &customStorageWrapper{
wrapped: server,
replace: replacedStorage,
}
return p.AppInstaller.InstallAPIs(wrappedServer, restOptsGetter)
}
// GetAuthorizer returns the authorizer for the plugins app.
func (p *PluginsAppInstaller) GetAuthorizer() authorizer.Authorizer {
return pluginsapp.GetAuthorizer()
}
+32
View File
@@ -0,0 +1,32 @@
package plugins
import (
"fmt"
appsdkapiserver "github.com/grafana/grafana-app-sdk/k8s/apiserver"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/apiserver/pkg/registry/rest"
genericserver "k8s.io/apiserver/pkg/server"
)
var _ appsdkapiserver.GenericAPIServer = (*customStorageWrapper)(nil)
type customStorageWrapper struct {
wrapped appsdkapiserver.GenericAPIServer
replace map[schema.GroupVersionResource]rest.Storage
}
func (c *customStorageWrapper) InstallAPIGroup(
apiGroupInfo *genericserver.APIGroupInfo,
) error {
if apiGroupInfo == nil || apiGroupInfo.VersionedResourcesStorageMap == nil {
return fmt.Errorf("apiGroupInfo cannot be nil")
}
for gvr, storage := range c.replace {
if _, ok := apiGroupInfo.VersionedResourcesStorageMap[gvr.Version]; !ok {
apiGroupInfo.VersionedResourcesStorageMap[gvr.Version] = map[string]rest.Storage{}
}
apiGroupInfo.VersionedResourcesStorageMap[gvr.Version][gvr.Resource] = storage
}
return c.wrapped.InstallAPIGroup(apiGroupInfo)
}
+2
View File
@@ -7,6 +7,7 @@ import (
"github.com/grafana/grafana/pkg/registry/apps/alerting/notifications"
"github.com/grafana/grafana/pkg/registry/apps/investigations"
"github.com/grafana/grafana/pkg/registry/apps/playlist"
"github.com/grafana/grafana/pkg/registry/apps/plugins"
"github.com/grafana/grafana/pkg/registry/apps/shorturl"
)
@@ -17,5 +18,6 @@ var WireSet = wire.NewSet(
investigations.RegisterApp,
advisor.RegisterApp,
notifications.RegisterApp,
plugins.RegisterAppInstaller,
shorturl.RegisterAppInstaller,
)
+11 -2
View File
@@ -77,6 +77,7 @@ import (
notifications2 "github.com/grafana/grafana/pkg/registry/apps/alerting/notifications"
"github.com/grafana/grafana/pkg/registry/apps/investigations"
"github.com/grafana/grafana/pkg/registry/apps/playlist"
"github.com/grafana/grafana/pkg/registry/apps/plugins"
"github.com/grafana/grafana/pkg/registry/apps/shorturl"
"github.com/grafana/grafana/pkg/registry/backgroundsvcs"
"github.com/grafana/grafana/pkg/registry/usagestatssvcs"
@@ -690,11 +691,15 @@ func Initialize(cfg *setting.Cfg, opts Options, apiOpts api.ServerOptions) (*Ser
if err != nil {
return nil, err
}
pluginsAppInstaller, err := plugins.RegisterAppInstaller(cfg, featureToggles)
if err != nil {
return nil, err
}
shortURLAppInstaller, err := shorturl.RegisterAppInstaller(cfg, shortURLService)
if err != nil {
return nil, err
}
v2 := appregistry.ProvideAppInstallers(featureToggles, playlistAppInstaller, shortURLAppInstaller)
v2 := appregistry.ProvideAppInstallers(featureToggles, playlistAppInstaller, pluginsAppInstaller, shortURLAppInstaller)
builderMetrics := builder.ProvideBuilderMetrics(registerer)
apiserverService, err := apiserver.ProvideService(cfg, featureToggles, routeRegisterImpl, tracingService, serverLockService, sqlStore, kvStore, middlewareHandler, scopedPluginDatasourceProvider, plugincontextProvider, pluginstoreService, dualwriteService, resourceClient, eventualRestConfigProvider, v, eventualRestConfigProvider, registerer, aggregatorRunner, v2, builderMetrics)
if err != nil {
@@ -1255,11 +1260,15 @@ func InitializeForTest(t sqlutil.ITestDB, testingT interface {
if err != nil {
return nil, err
}
pluginsAppInstaller, err := plugins.RegisterAppInstaller(cfg, featureToggles)
if err != nil {
return nil, err
}
shortURLAppInstaller, err := shorturl.RegisterAppInstaller(cfg, shortURLService)
if err != nil {
return nil, err
}
v2 := appregistry.ProvideAppInstallers(featureToggles, playlistAppInstaller, shortURLAppInstaller)
v2 := appregistry.ProvideAppInstallers(featureToggles, playlistAppInstaller, pluginsAppInstaller, shortURLAppInstaller)
builderMetrics := builder.ProvideBuilderMetrics(registerer)
apiserverService, err := apiserver.ProvideService(cfg, featureToggles, routeRegisterImpl, tracingService, serverLockService, sqlStore, kvStore, middlewareHandler, scopedPluginDatasourceProvider, plugincontextProvider, pluginstoreService, dualwriteService, resourceClient, eventualRestConfigProvider, v, eventualRestConfigProvider, registerer, aggregatorRunner, v2, builderMetrics)
if err != nil {
+90
View File
@@ -0,0 +1,90 @@
package plugins
import (
"testing"
"github.com/stretchr/testify/require"
)
func TestPluginsIntegrationDiscovery(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test")
}
t.Run("discovery", func(t *testing.T) {
helper := setupHelper(t)
disco := helper.GetGroupVersionInfoJSON("plugins.grafana.app")
require.JSONEq(t, `[
{
"version": "v0alpha1",
"freshness": "Current",
"resources": [
{
"resource": "plugininstalls",
"responseKind": {
"group": "",
"kind": "PluginInstall",
"version": ""
},
"scope": "Namespaced",
"singularResource": "plugininstalls",
"subresources": [
{
"responseKind": {
"group": "",
"kind": "PluginInstall",
"version": ""
},
"subresource": "status",
"verbs": [
"get",
"patch",
"update"
]
}
],
"verbs": [
"create",
"delete",
"deletecollection",
"get",
"list",
"patch",
"update",
"watch"
]
},
{
"resource": "pluginmetas",
"responseKind": {
"group": "",
"kind": "PluginMeta",
"version": ""
},
"scope": "Namespaced",
"singularResource": "pluginmeta",
"subresources": [
{
"responseKind": {
"group": "",
"kind": "PluginMeta",
"version": ""
},
"subresource": "status",
"verbs": [
"get",
"patch",
"update"
]
}
],
"verbs": [
"get",
"list"
]
}
]
}
]`, disco)
})
}
@@ -0,0 +1,313 @@
package plugins
import (
"context"
"fmt"
"testing"
"github.com/stretchr/testify/require"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/runtime/schema"
"github.com/grafana/grafana/pkg/tests/apis"
"github.com/grafana/grafana/pkg/tests/testinfra"
"github.com/grafana/grafana/pkg/tests/testsuite"
)
var gvrPluginInstalls = schema.GroupVersionResource{
Group: "plugins.grafana.app",
Version: "v0alpha1",
Resource: "plugininstalls",
}
func TestMain(m *testing.M) {
testsuite.Run(m)
}
func TestIntegrationPluginInstalls(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test")
}
t.Run("create plugin install", func(t *testing.T) {
helper := setupHelper(t)
ctx := context.Background()
client := helper.GetResourceClient(apis.ResourceClientArgs{
User: helper.Org1.Admin,
GVR: gvrPluginInstalls,
})
pluginName := "test-plugin-create"
pluginInstall := helper.LoadYAMLOrJSON(fmt.Sprintf(`{
"apiVersion": "plugins.grafana.app/v0alpha1",
"kind": "PluginInstall",
"metadata": {"name": "%s"},
"spec": {"version": "1.0.0"}
}`, pluginName))
created, err := client.Resource.Create(ctx, pluginInstall, metav1.CreateOptions{})
require.NoError(t, err)
require.NotNil(t, created)
require.Equal(t, pluginName, created.GetName())
})
t.Run("create plugin install with status is ignored", func(t *testing.T) {
t.Skip("status is not ignored on create. this might require a change in the SDK. skipping for now")
helper := setupHelper(t)
ctx := context.Background()
client := helper.GetResourceClient(apis.ResourceClientArgs{
User: helper.Org1.Admin,
GVR: gvrPluginInstalls,
})
pluginName := "test-plugin-create-with-status"
pluginInstall := helper.LoadYAMLOrJSON(fmt.Sprintf(`{
"apiVersion": "plugins.grafana.app/v0alpha1",
"kind": "PluginInstall",
"metadata": {"name": "%s"},
"spec": {"version": "1.0.0"},
"status": {
"operatorStates": {
"test-operator": {
"lastEvaluation": "1",
"state": "success"
}
}
}
}`, pluginName))
created, err := client.Resource.Create(ctx, pluginInstall, metav1.CreateOptions{})
require.NoError(t, err)
require.NotNil(t, created)
require.Equal(t, pluginName, created.GetName())
// Status should be empty as it's ignored on create
status, found, err := unstructured.NestedMap(created.Object, "status")
require.NoError(t, err)
require.True(t, found) // status field should exist
require.Empty(t, status) // but it should be empty
})
t.Run("get plugin install", func(t *testing.T) {
helper := setupHelper(t)
ctx := context.Background()
client := helper.GetResourceClient(apis.ResourceClientArgs{
User: helper.Org1.Admin,
GVR: gvrPluginInstalls,
})
pluginName := "test-plugin-get"
pluginInstall := helper.LoadYAMLOrJSON(fmt.Sprintf(`{
"apiVersion": "plugins.grafana.app/v0alpha1",
"kind": "PluginInstall",
"metadata": {"name": "%s"},
"spec": {"version": "1.0.0"}
}`, pluginName))
created, err := client.Resource.Create(ctx, pluginInstall, metav1.CreateOptions{})
require.NoError(t, err)
fetched, err := client.Resource.Get(ctx, pluginName, metav1.GetOptions{})
require.NoError(t, err)
require.NotNil(t, fetched)
require.Equal(t, pluginName, fetched.GetName())
require.Equal(t, created.Object, fetched.Object)
})
t.Run("update plugin install", func(t *testing.T) {
helper := setupHelper(t)
ctx := context.Background()
client := helper.GetResourceClient(apis.ResourceClientArgs{
User: helper.Org1.Admin,
GVR: gvrPluginInstalls,
})
pluginName := "test-plugin-update"
pluginInstall := helper.LoadYAMLOrJSON(fmt.Sprintf(`{
"apiVersion": "plugins.grafana.app/v0alpha1",
"kind": "PluginInstall",
"metadata": {"name": "%s"},
"spec": {"version": "1.0.0"}
}`, pluginName))
created, err := client.Resource.Create(ctx, pluginInstall, metav1.CreateOptions{})
require.NoError(t, err)
updatedSpec := created.DeepCopy()
updatedSpec.Object["spec"] = map[string]interface{}{
"version": "2.0.0",
}
updated, err := client.Resource.Update(ctx, updatedSpec, metav1.UpdateOptions{})
require.NoError(t, err)
require.NotNil(t, updated)
require.Equal(t, "2.0.0", updated.Object["spec"].(map[string]interface{})["version"])
})
t.Run("update plugin install with status is ignored", func(t *testing.T) {
helper := setupHelper(t)
ctx := context.Background()
client := helper.GetResourceClient(apis.ResourceClientArgs{
User: helper.Org1.Admin,
GVR: gvrPluginInstalls,
})
pluginName := "test-plugin-update-with-status"
pluginInstall := helper.LoadYAMLOrJSON(fmt.Sprintf(`{
"apiVersion": "plugins.grafana.app/v0alpha1",
"kind": "PluginInstall",
"metadata": {"name": "%s"},
"spec": {"version": "1.0.0"}
}`, pluginName))
created, err := client.Resource.Create(ctx, pluginInstall, metav1.CreateOptions{})
require.NoError(t, err)
// Try to update the status via a normal update
withStatus := created.DeepCopy()
withStatus.Object["status"] = map[string]interface{}{
"operatorStates": map[string]interface{}{
"test-operator": map[string]interface{}{
"lastEvaluation": "1",
"state": "success",
},
},
}
updated, err := client.Resource.Update(ctx, withStatus, metav1.UpdateOptions{})
require.NoError(t, err)
require.NotNil(t, updated)
// The status should not have been updated
status, found, err := unstructured.NestedMap(updated.Object, "status")
require.NoError(t, err)
require.True(t, found)
require.Empty(t, status)
// also check with get
fetched, err := client.Resource.Get(ctx, pluginName, metav1.GetOptions{})
require.NoError(t, err)
require.NotNil(t, fetched)
status, found, err = unstructured.NestedMap(fetched.Object, "status")
require.NoError(t, err)
require.True(t, found)
require.Empty(t, status)
})
t.Run("update plugin install status", func(t *testing.T) {
helper := setupHelper(t)
ctx := context.Background()
client := helper.GetResourceClient(apis.ResourceClientArgs{
User: helper.Org1.Admin,
GVR: gvrPluginInstalls,
})
pluginName := "test-plugin-status"
pluginInstall := helper.LoadYAMLOrJSON(fmt.Sprintf(`{
"apiVersion": "plugins.grafana.app/v0alpha1",
"kind": "PluginInstall",
"metadata": {"name": "%s"},
"spec": {"version": "1.0.0"}
}`, pluginName))
created, err := client.Resource.Create(ctx, pluginInstall, metav1.CreateOptions{})
require.NoError(t, err)
// Update the status
status := created.DeepCopy()
statusPayload := map[string]interface{}{
"operatorStates": map[string]interface{}{
"test-operator": map[string]interface{}{
"lastEvaluation": "1",
"state": "success",
},
},
}
status.Object["status"] = statusPayload
updated, err := client.Resource.UpdateStatus(ctx, status, metav1.UpdateOptions{})
require.NoError(t, err)
require.NotNil(t, updated)
// Check the status on the returned object
actualStatus, found, err := unstructured.NestedMap(updated.Object, "status")
require.NoError(t, err)
require.True(t, found)
require.Equal(t, statusPayload, actualStatus)
// Get the status to ensure it persisted
fetched, err := client.Resource.Get(ctx, pluginName, metav1.GetOptions{})
require.NoError(t, err)
require.NotNil(t, fetched)
actualStatus, found, err = unstructured.NestedMap(fetched.Object, "status")
require.NoError(t, err)
require.True(t, found)
require.Equal(t, statusPayload, actualStatus)
})
t.Run("list plugin installs", func(t *testing.T) {
helper := setupHelper(t)
ctx := context.Background()
client := helper.GetResourceClient(apis.ResourceClientArgs{
User: helper.Org1.Admin,
GVR: gvrPluginInstalls,
})
pluginName := "test-plugin-list"
pluginInstall := helper.LoadYAMLOrJSON(fmt.Sprintf(`{
"apiVersion": "plugins.grafana.app/v0alpha1",
"kind": "PluginInstall",
"metadata": {"name": "%s"},
"spec": {"version": "1.0.0"}
}`, pluginName))
created, err := client.Resource.Create(ctx, pluginInstall, metav1.CreateOptions{})
require.NoError(t, err)
list, err := client.Resource.List(ctx, metav1.ListOptions{})
require.NoError(t, err)
expectedItems := []unstructured.Unstructured{*created}
require.ElementsMatch(t, expectedItems, list.Items)
})
t.Run("delete plugin install", func(t *testing.T) {
helper := setupHelper(t)
ctx := context.Background()
client := helper.GetResourceClient(apis.ResourceClientArgs{
User: helper.Org1.Admin,
GVR: gvrPluginInstalls,
})
pluginName := "test-plugin-delete"
pluginInstall := helper.LoadYAMLOrJSON(fmt.Sprintf(`{
"apiVersion": "plugins.grafana.app/v0alpha1",
"kind": "PluginInstall",
"metadata": {"name": "%s"},
"spec": {"version": "1.0.0"}
}`, pluginName))
_, err := client.Resource.Create(ctx, pluginInstall, metav1.CreateOptions{})
require.NoError(t, err)
err = client.Resource.Delete(ctx, pluginName, metav1.DeleteOptions{})
require.NoError(t, err)
_, err = client.Resource.Get(ctx, pluginName, metav1.GetOptions{})
statusError := helper.AsStatusError(err)
require.Equal(t, metav1.StatusReasonNotFound, statusError.Status().Reason)
})
t.Run("insufficient permissions", func(t *testing.T) {
helper := setupHelper(t)
for _, user := range []apis.User{
helper.Org1.Editor,
helper.Org1.Viewer,
} {
t.Run(fmt.Sprintf("with basic role: %s", user.Identity.GetOrgRole()), func(t *testing.T) {
client := helper.GetResourceClient(apis.ResourceClientArgs{
User: user,
GVR: gvrPluginInstalls,
})
pluginInstall := helper.LoadYAMLOrJSON(`{
"apiVersion": "plugins.grafana.app/v0alpha1",
"kind": "PluginInstall",
"metadata": {"name": "test-plugin"},
"spec": {"version": "1.0.0"}
}`)
_, err := client.Resource.Create(context.Background(), pluginInstall, metav1.CreateOptions{})
statusError := helper.AsStatusError(err)
require.Equal(t, metav1.StatusReasonForbidden, statusError.Status().Reason)
err = client.Resource.Delete(context.Background(), "test-plugin", metav1.DeleteOptions{})
statusError = helper.AsStatusError(err)
require.Equal(t, metav1.StatusReasonForbidden, statusError.Status().Reason)
})
}
})
}
func setupHelper(t *testing.T) *apis.K8sTestHelper {
t.Helper()
helper := apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{
AppModeProduction: true,
DisableAnonymous: true,
APIServerRuntimeConfig: "plugins.grafana.app/v0alpha1=true",
})
t.Cleanup(func() { helper.Shutdown() })
return helper
}
@@ -0,0 +1,48 @@
package plugins
import (
"context"
"testing"
"github.com/stretchr/testify/require"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime/schema"
"github.com/grafana/grafana/pkg/tests/apis"
)
var gvrPluginMeta = schema.GroupVersionResource{
Group: "plugins.grafana.app",
Version: "v0alpha1",
Resource: "pluginmetas",
}
func TestIntegrationPluginMeta(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test")
}
t.Run("list plugin metas", func(t *testing.T) {
helper := setupHelper(t)
ctx := context.Background()
client := helper.GetResourceClient(apis.ResourceClientArgs{
User: helper.Org1.Admin,
GVR: gvrPluginMeta,
})
list, err := client.Resource.List(ctx, metav1.ListOptions{})
require.NoError(t, err)
require.NotNil(t, list)
require.Empty(t, list.Items)
})
t.Run("get plugin meta", func(t *testing.T) {
helper := setupHelper(t)
ctx := context.Background()
client := helper.GetResourceClient(apis.ResourceClientArgs{
User: helper.Org1.Admin,
GVR: gvrPluginMeta,
})
_, err := client.Resource.Get(ctx, "example", metav1.GetOptions{})
require.Error(t, err)
})
}
+7
View File
@@ -525,6 +525,12 @@ func CreateGrafDir(t *testing.T, opts GrafanaOpts) (string, string) {
require.NoError(t, err)
}
if opts.APIServerRuntimeConfig != "" {
section, err := getOrCreateSection("grafana-apiserver")
require.NoError(t, err)
_, err = section.NewKey("runtime_config", opts.APIServerRuntimeConfig)
require.NoError(t, err)
}
dbSection, err := getOrCreateSection("database")
require.NoError(t, err)
_, err = dbSection.NewKey("query_retries", fmt.Sprintf("%d", queryRetries))
@@ -582,6 +588,7 @@ type GrafanaOpts struct {
LicensePath string
EnableRecordingRules bool
EnableSCIM bool
APIServerRuntimeConfig string
// When "unified-grpc" is selected it will also start the grpc server
APIServerStorageType options.StorageType