diff --git a/apps/iam/go.mod b/apps/iam/go.mod index 7db5d2ec29c..d0934bdf950 100644 --- a/apps/iam/go.mod +++ b/apps/iam/go.mod @@ -240,6 +240,7 @@ require ( github.com/grafana/grafana-azure-sdk-go/v2 v2.3.1 // indirect github.com/grafana/grafana-plugin-sdk-go v0.281.0 // indirect github.com/grafana/grafana/apps/dashboard v0.0.0 // indirect + github.com/grafana/grafana/apps/plugins v0.0.0 // indirect github.com/grafana/grafana/apps/provisioning v0.0.0 // indirect github.com/grafana/grafana/apps/secret v0.0.0 // indirect github.com/grafana/grafana/pkg/aggregator v0.0.0 // indirect diff --git a/apps/plugins/kinds/plugininstall.cue b/apps/plugins/kinds/plugininstall.cue index 82181fda1d4..d72cfac7342 100644 --- a/apps/plugins/kinds/plugininstall.cue +++ b/apps/plugins/kinds/plugininstall.cue @@ -5,9 +5,11 @@ pluginInstallV0Alpha1: { plural: "plugininstalls" scope: "Namespaced" schema: { - spec: { - id: string - version: string - } + spec: { + id: string + version: string + url?: string + class: "core" | "external" | "cdn" + } } } diff --git a/apps/plugins/pkg/apis/plugins/v0alpha1/plugininstall_spec_gen.go b/apps/plugins/pkg/apis/plugins/v0alpha1/plugininstall_spec_gen.go index c815a69eac2..eeccd7f656d 100644 --- a/apps/plugins/pkg/apis/plugins/v0alpha1/plugininstall_spec_gen.go +++ b/apps/plugins/pkg/apis/plugins/v0alpha1/plugininstall_spec_gen.go @@ -4,11 +4,22 @@ package v0alpha1 // +k8s:openapi-gen=true type PluginInstallSpec struct { - Id string `json:"id"` - Version string `json:"version"` + Id string `json:"id"` + Version string `json:"version"` + Url *string `json:"url,omitempty"` + Class PluginInstallSpecClass `json:"class"` } // NewPluginInstallSpec creates a new PluginInstallSpec object. func NewPluginInstallSpec() *PluginInstallSpec { return &PluginInstallSpec{} } + +// +k8s:openapi-gen=true +type PluginInstallSpecClass string + +const ( + PluginInstallSpecClassCore PluginInstallSpecClass = "core" + PluginInstallSpecClassExternal PluginInstallSpecClass = "external" + PluginInstallSpecClassCdn PluginInstallSpecClass = "cdn" +) diff --git a/apps/plugins/pkg/apis/plugins_manifest.go b/apps/plugins/pkg/apis/plugins_manifest.go index 4b0771aca29..84a55555c2b 100644 --- a/apps/plugins/pkg/apis/plugins_manifest.go +++ b/apps/plugins/pkg/apis/plugins_manifest.go @@ -23,7 +23,7 @@ var ( rawSchemaPluginMetav0alpha1 = []byte(`{"Dependencies":{"additionalProperties":false,"properties":{"extensions":{"additionalProperties":false,"properties":{"exposedComponents":{"description":"+listType=set","items":{"type":"string"},"type":"array"}},"type":"object"},"grafanaDependency":{"description":"Required field","type":"string"},"grafanaVersion":{"description":"Optional fields","type":"string"},"plugins":{"description":"+listType=set\n+listMapKey=id","items":{"additionalProperties":false,"properties":{"id":{"type":"string"},"name":{"type":"string"},"type":{"enum":["app","datasource","panel"],"type":"string"}},"required":["id","type","name"],"type":"object"},"type":"array"}},"required":["grafanaDependency"],"type":"object"},"EnterpriseFeatures":{"additionalProperties":false,"properties":{"healthDiagnosticsErrors":{"default":false,"description":"Allow additional properties","type":"boolean"}},"type":"object"},"Extensions":{"additionalProperties":false,"properties":{"addedComponents":{"description":"+listType=atomic","items":{"additionalProperties":false,"properties":{"description":{"type":"string"},"targets":{"description":"+listType=set","items":{"type":"string"},"type":"array"},"title":{"type":"string"}},"required":["targets","title"],"type":"object"},"type":"array"},"addedLinks":{"description":"+listType=atomic","items":{"additionalProperties":false,"properties":{"description":{"type":"string"},"targets":{"description":"+listType=set","items":{"type":"string"},"type":"array"},"title":{"type":"string"}},"required":["targets","title"],"type":"object"},"type":"array"},"exposedComponents":{"description":"+listType=set\n+listMapKey=id","items":{"additionalProperties":false,"properties":{"description":{"type":"string"},"id":{"type":"string"},"title":{"type":"string"}},"required":["id"],"type":"object"},"type":"array"},"extensionPoints":{"description":"+listType=set\n+listMapKey=id","items":{"additionalProperties":false,"properties":{"description":{"type":"string"},"id":{"type":"string"},"title":{"type":"string"}},"required":["id"],"type":"object"},"type":"array"}},"type":"object"},"IAM":{"additionalProperties":false,"properties":{"permissions":{"description":"+listType=atomic","items":{"additionalProperties":false,"properties":{"action":{"type":"string"},"scope":{"type":"string"}},"type":"object"},"type":"array"}},"type":"object"},"Include":{"additionalProperties":false,"properties":{"action":{"type":"string"},"addToNav":{"type":"boolean"},"component":{"type":"string"},"defaultNav":{"type":"boolean"},"icon":{"type":"string"},"name":{"type":"string"},"path":{"type":"string"},"role":{"enum":["Admin","Editor","Viewer"],"type":"string"},"type":{"enum":["dashboard","page","panel","datasource"],"type":"string"},"uid":{"type":"string"}},"type":"object"},"Info":{"additionalProperties":false,"properties":{"author":{"additionalProperties":false,"description":"Optional fields","properties":{"email":{"type":"string"},"name":{"type":"string"},"url":{"type":"string"}},"type":"object"},"description":{"type":"string"},"keywords":{"description":"Required fields\n+listType=set","items":{"type":"string"},"type":"array"},"links":{"description":"+listType=atomic","items":{"additionalProperties":false,"properties":{"name":{"type":"string"},"url":{"type":"string"}},"type":"object"},"type":"array"},"logos":{"additionalProperties":false,"properties":{"large":{"type":"string"},"small":{"type":"string"}},"required":["small","large"],"type":"object"},"screenshots":{"description":"+listType=atomic","items":{"additionalProperties":false,"properties":{"name":{"type":"string"},"path":{"type":"string"}},"type":"object"},"type":"array"},"updated":{"format":"date-time","type":"string"},"version":{"type":"string"}},"required":["keywords","logos","updated","version"],"type":"object"},"JSONData":{"additionalProperties":false,"description":"JSON configuration schema for Grafana plugins\nConverted from: https://github.com/grafana/grafana/blob/main/docs/sources/developers/plugins/plugin.schema.json","properties":{"alerting":{"description":"Optional fields","type":"boolean"},"annotations":{"type":"boolean"},"autoEnabled":{"type":"boolean"},"backend":{"type":"boolean"},"buildMode":{"type":"string"},"builtIn":{"type":"boolean"},"category":{"enum":["tsdb","logging","cloud","tracing","profiling","sql","enterprise","iot","other"],"type":"string"},"dependencies":{"$ref":"#/components/schemas/Dependencies","description":"Dependency information"},"enterpriseFeatures":{"$ref":"#/components/schemas/EnterpriseFeatures"},"executable":{"type":"string"},"extensions":{"$ref":"#/components/schemas/Extensions"},"hideFromList":{"type":"boolean"},"iam":{"$ref":"#/components/schemas/IAM"},"id":{"description":"Unique name of the plugin","type":"string"},"includes":{"description":"+listType=atomic","items":{"$ref":"#/components/schemas/Include"},"type":"array"},"info":{"$ref":"#/components/schemas/Info","description":"Metadata for the plugin"},"logs":{"type":"boolean"},"metrics":{"type":"boolean"},"multiValueFilterOperators":{"type":"boolean"},"name":{"description":"Human-readable name of the plugin","type":"string"},"pascalName":{"type":"string"},"preload":{"type":"boolean"},"queryOptions":{"$ref":"#/components/schemas/QueryOptions"},"roles":{"description":"+listType=atomic","items":{"$ref":"#/components/schemas/Role"},"type":"array"},"routes":{"description":"+listType=atomic","items":{"$ref":"#/components/schemas/Route"},"type":"array"},"skipDataQuery":{"type":"boolean"},"state":{"enum":["alpha","beta"],"type":"string"},"streaming":{"type":"boolean"},"tracing":{"type":"boolean"},"type":{"description":"Plugin type","enum":["app","datasource","panel","renderer"],"type":"string"}},"required":["id","type","name","info","dependencies"],"type":"object"},"OperatorState":{"additionalProperties":false,"properties":{"descriptiveState":{"description":"descriptiveState is an optional more descriptive state field which has no requirements on format","type":"string"},"details":{"additionalProperties":{"additionalProperties":{},"type":"object"},"description":"details contains any extra information that is operator-specific","type":"object"},"lastEvaluation":{"description":"lastEvaluation is the ResourceVersion last evaluated","type":"string"},"state":{"description":"state describes the state of the lastEvaluation.\nIt is limited to three possible states for machine evaluation.","enum":["success","in_progress","failed"],"type":"string"}},"required":["lastEvaluation","state"],"type":"object"},"PluginMeta":{"properties":{"spec":{"$ref":"#/components/schemas/spec"},"status":{"$ref":"#/components/schemas/status"}},"required":["spec"]},"QueryOptions":{"additionalProperties":false,"properties":{"cacheTimeout":{"type":"boolean"},"maxDataPoints":{"type":"boolean"},"minInterval":{"type":"boolean"}},"type":"object"},"Role":{"additionalProperties":false,"properties":{"grants":{"description":"+listType=set","items":{"type":"string"},"type":"array"},"role":{"additionalProperties":false,"properties":{"description":{"type":"string"},"name":{"type":"string"},"permissions":{"description":"+listType=atomic","items":{"additionalProperties":false,"properties":{"action":{"type":"string"},"scope":{"type":"string"}},"type":"object"},"type":"array"}},"type":"object"}},"type":"object"},"Route":{"additionalProperties":false,"properties":{"body":{"additionalProperties":{"additionalProperties":{},"type":"object"},"type":"object"},"headers":{"description":"+listType=atomic","items":{"type":"string"},"type":"array"},"jwtTokenAuth":{"additionalProperties":false,"properties":{"params":{"additionalProperties":{"additionalProperties":{},"type":"object"},"type":"object"},"scopes":{"description":"+listType=set","items":{"type":"string"},"type":"array"},"url":{"type":"string"}},"type":"object"},"method":{"type":"string"},"path":{"type":"string"},"reqAction":{"type":"string"},"reqRole":{"type":"string"},"reqSignedIn":{"type":"boolean"},"tokenAuth":{"additionalProperties":false,"properties":{"params":{"additionalProperties":{"additionalProperties":{},"type":"object"},"type":"object"},"scopes":{"description":"+listType=set","items":{"type":"string"},"type":"array"},"url":{"type":"string"}},"type":"object"},"url":{"type":"string"},"urlParams":{"description":"+listType=atomic","items":{"additionalProperties":false,"properties":{"content":{"type":"string"},"name":{"type":"string"}},"type":"object"},"type":"array"}},"type":"object"},"spec":{"additionalProperties":false,"properties":{"pluginJSON":{"$ref":"#/components/schemas/JSONData"}},"required":["pluginJSON"],"type":"object"},"status":{"additionalProperties":false,"properties":{"additionalFields":{"additionalProperties":{"additionalProperties":{},"type":"object"},"description":"additionalFields is reserved for future use","type":"object"},"operatorStates":{"additionalProperties":{"$ref":"#/components/schemas/OperatorState"},"description":"operatorStates is a map of operator ID to operator state evaluations.\nAny operator which consumes this kind SHOULD add its state evaluation information to this field.","type":"object"}},"type":"object"}}`) versionSchemaPluginMetav0alpha1 app.VersionSchema _ = json.Unmarshal(rawSchemaPluginMetav0alpha1, &versionSchemaPluginMetav0alpha1) - rawSchemaPluginInstallv0alpha1 = []byte(`{"OperatorState":{"additionalProperties":false,"properties":{"descriptiveState":{"description":"descriptiveState is an optional more descriptive state field which has no requirements on format","type":"string"},"details":{"additionalProperties":{"additionalProperties":{},"type":"object"},"description":"details contains any extra information that is operator-specific","type":"object"},"lastEvaluation":{"description":"lastEvaluation is the ResourceVersion last evaluated","type":"string"},"state":{"description":"state describes the state of the lastEvaluation.\nIt is limited to three possible states for machine evaluation.","enum":["success","in_progress","failed"],"type":"string"}},"required":["lastEvaluation","state"],"type":"object"},"PluginInstall":{"properties":{"spec":{"$ref":"#/components/schemas/spec"},"status":{"$ref":"#/components/schemas/status"}},"required":["spec"]},"spec":{"additionalProperties":false,"properties":{"id":{"type":"string"},"version":{"type":"string"}},"required":["id","version"],"type":"object"},"status":{"additionalProperties":false,"properties":{"additionalFields":{"additionalProperties":{"additionalProperties":{},"type":"object"},"description":"additionalFields is reserved for future use","type":"object"},"operatorStates":{"additionalProperties":{"$ref":"#/components/schemas/OperatorState"},"description":"operatorStates is a map of operator ID to operator state evaluations.\nAny operator which consumes this kind SHOULD add its state evaluation information to this field.","type":"object"}},"type":"object"}}`) + rawSchemaPluginInstallv0alpha1 = []byte(`{"OperatorState":{"additionalProperties":false,"properties":{"descriptiveState":{"description":"descriptiveState is an optional more descriptive state field which has no requirements on format","type":"string"},"details":{"additionalProperties":{"additionalProperties":{},"type":"object"},"description":"details contains any extra information that is operator-specific","type":"object"},"lastEvaluation":{"description":"lastEvaluation is the ResourceVersion last evaluated","type":"string"},"state":{"description":"state describes the state of the lastEvaluation.\nIt is limited to three possible states for machine evaluation.","enum":["success","in_progress","failed"],"type":"string"}},"required":["lastEvaluation","state"],"type":"object"},"PluginInstall":{"properties":{"spec":{"$ref":"#/components/schemas/spec"},"status":{"$ref":"#/components/schemas/status"}},"required":["spec"]},"spec":{"additionalProperties":false,"properties":{"class":{"enum":["core","external","cdn"],"type":"string"},"id":{"type":"string"},"url":{"type":"string"},"version":{"type":"string"}},"required":["id","version","class"],"type":"object"},"status":{"additionalProperties":false,"properties":{"additionalFields":{"additionalProperties":{"additionalProperties":{},"type":"object"},"description":"additionalFields is reserved for future use","type":"object"},"operatorStates":{"additionalProperties":{"$ref":"#/components/schemas/OperatorState"},"description":"operatorStates is a map of operator ID to operator state evaluations.\nAny operator which consumes this kind SHOULD add its state evaluation information to this field.","type":"object"}},"type":"object"}}`) versionSchemaPluginInstallv0alpha1 app.VersionSchema _ = json.Unmarshal(rawSchemaPluginInstallv0alpha1, &versionSchemaPluginInstallv0alpha1) ) diff --git a/apps/plugins/pkg/app/install/registrar.go b/apps/plugins/pkg/app/install/registrar.go new file mode 100644 index 00000000000..efb7de11080 --- /dev/null +++ b/apps/plugins/pkg/app/install/registrar.go @@ -0,0 +1,163 @@ +package install + +import ( + "context" + "sync" + + "github.com/grafana/grafana-app-sdk/resource" + errorsK8s "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + pluginsv0alpha1 "github.com/grafana/grafana/apps/plugins/pkg/apis/plugins/v0alpha1" +) + +const ( + PluginInstallSourceAnnotation = "plugins.grafana.app/install-source" +) + +// Class represents the plugin class type in an unversioned internal format. +// This intentionally duplicates the versioned API type (PluginInstallSpecClass) to decouple +// internal code from API version changes, making it easier to support multiple API versions. +type Class = string + +const ( + ClassCore Class = "core" + ClassExternal Class = "external" + ClassCDN Class = "cdn" +) + +type Source = string + +const ( + SourceUnknown Source = "unknown" + SourcePluginStore Source = "plugin-store" +) + +type PluginInstall struct { + ID string + Version string + URL string + Class Class + Source Source +} + +func (p *PluginInstall) ToPluginInstallV0Alpha1(namespace string) *pluginsv0alpha1.PluginInstall { + var url *string = nil + if p.URL != "" { + url = &p.URL + } + return &pluginsv0alpha1.PluginInstall{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: namespace, + Name: p.ID, + Annotations: map[string]string{ + PluginInstallSourceAnnotation: p.Source, + }, + }, + Spec: pluginsv0alpha1.PluginInstallSpec{ + Id: p.ID, + Version: p.Version, + Url: url, + Class: pluginsv0alpha1.PluginInstallSpecClass(p.Class), + }, + } +} + +func (p *PluginInstall) ShouldUpdate(existing *pluginsv0alpha1.PluginInstall) bool { + update := p.ToPluginInstallV0Alpha1(existing.Namespace) + if source, ok := existing.Annotations[PluginInstallSourceAnnotation]; ok && source != p.Source { + return true + } + if existing.Spec.Version != update.Spec.Version { + return true + } + if existing.Spec.Class != update.Spec.Class { + return true // this should never really happen + } + if !equalStringPointers(existing.Spec.Url, update.Spec.Url) { + return true + } + return false +} + +func equalStringPointers(a, b *string) bool { + if a == nil && b == nil { + return true + } + if a == nil || b == nil { + return false + } + return *a == *b +} + +type InstallRegistrar struct { + clientGenerator resource.ClientGenerator + client *pluginsv0alpha1.PluginInstallClient + clientOnce sync.Once +} + +func NewInstallRegistrar(clientGenerator resource.ClientGenerator) *InstallRegistrar { + return &InstallRegistrar{ + clientGenerator: clientGenerator, + clientOnce: sync.Once{}, + } +} + +func (r *InstallRegistrar) GetClient() (*pluginsv0alpha1.PluginInstallClient, error) { + r.clientOnce.Do(func() { + client, err := pluginsv0alpha1.NewPluginInstallClientFromGenerator(r.clientGenerator) + if err != nil { + r.client = nil + return + } + r.client = client + }) + + return r.client, nil +} + +// Register creates or updates a plugin install in the registry. +func (r *InstallRegistrar) Register(ctx context.Context, namespace string, install *PluginInstall) error { + client, err := r.GetClient() + if err != nil { + return nil + } + identifier := resource.Identifier{ + Namespace: namespace, + Name: install.ID, + } + + existing, err := client.Get(ctx, identifier) + if err != nil && !errorsK8s.IsNotFound(err) { + return err + } + + if existing != nil && install.ShouldUpdate(existing) { + _, err = client.Update(ctx, install.ToPluginInstallV0Alpha1(namespace), resource.UpdateOptions{ResourceVersion: existing.ResourceVersion}) + return err + } + + _, err = client.Create(ctx, install.ToPluginInstallV0Alpha1(namespace), resource.CreateOptions{}) + return err +} + +// Unregister removes a plugin install from the registry. +func (r *InstallRegistrar) Unregister(ctx context.Context, namespace string, name string, source Source) error { + client, err := r.GetClient() + if err != nil { + return err + } + identifier := resource.Identifier{ + Namespace: namespace, + Name: name, + } + existing, err := client.Get(ctx, identifier) + if err != nil && !errorsK8s.IsNotFound(err) { + return err + } + // if the source is different, do not unregister + if existingSource, ok := existing.Annotations[PluginInstallSourceAnnotation]; ok && existingSource != source { + return nil + } + return client.Delete(ctx, identifier, resource.DeleteOptions{}) +} diff --git a/packages/grafana-data/src/types/featureToggles.gen.ts b/packages/grafana-data/src/types/featureToggles.gen.ts index c070fc97022..2855555733a 100644 --- a/packages/grafana-data/src/types/featureToggles.gen.ts +++ b/packages/grafana-data/src/types/featureToggles.gen.ts @@ -1208,6 +1208,11 @@ export interface FeatureToggles { */ cdnPluginsUrls?: boolean; /** + * Enable syncing plugin installations to the installs API + * @default false + */ + pluginInstallAPISync?: boolean; + /** * Enable new gauge visualization * @default false */ diff --git a/pkg/api/plugins_test.go b/pkg/api/plugins_test.go index bda739c5020..47ba2f774e5 100644 --- a/pkg/api/plugins_test.go +++ b/pkg/api/plugins_test.go @@ -40,6 +40,7 @@ import ( "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/services/org/orgtest" + "github.com/grafana/grafana/pkg/services/pluginsintegration/installsync/installsyncfakes" "github.com/grafana/grafana/pkg/services/pluginsintegration/managedplugins" "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginaccesscontrol" "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginassets" @@ -527,7 +528,7 @@ func callGetPluginAsset(sc *scenarioContext) { func pluginAssetScenario(t *testing.T, desc string, url string, urlPattern string, cfg *setting.Cfg, pluginRegistry registry.Service, fn scenarioFunc) { t.Run(fmt.Sprintf("%s %s", desc, url), func(t *testing.T) { - store, err := pluginstore.NewPluginStoreForTest(pluginRegistry, &pluginfakes.FakeLoader{}, &pluginfakes.FakeSourceRegistry{}) + store, err := pluginstore.NewPluginStoreForTest(pluginRegistry, &pluginfakes.FakeLoader{}, &pluginfakes.FakeSourceRegistry{}, installsyncfakes.NewFakeSyncer()) require.NoError(t, err) hs := HTTPServer{ @@ -642,7 +643,7 @@ func Test_PluginsList_AccessControl(t *testing.T) { for _, tc := range tcs { t.Run(tc.desc, func(t *testing.T) { server := SetupAPITestServer(t, func(hs *HTTPServer) { - store, err := pluginstore.NewPluginStoreForTest(pluginRegistry, &pluginfakes.FakeLoader{}, &pluginfakes.FakeSourceRegistry{}) + store, err := pluginstore.NewPluginStoreForTest(pluginRegistry, &pluginfakes.FakeLoader{}, &pluginfakes.FakeSourceRegistry{}, installsyncfakes.NewFakeSyncer()) require.NoError(t, err) hs.Cfg = setting.NewCfg() @@ -832,7 +833,7 @@ func Test_PluginsSettings(t *testing.T) { for _, tc := range tcs { t.Run(tc.desc, func(t *testing.T) { server := SetupAPITestServer(t, func(hs *HTTPServer) { - store, err := pluginstore.NewPluginStoreForTest(pluginRegistry, &pluginfakes.FakeLoader{}, &pluginfakes.FakeSourceRegistry{}) + store, err := pluginstore.NewPluginStoreForTest(pluginRegistry, &pluginfakes.FakeLoader{}, &pluginfakes.FakeSourceRegistry{}, installsyncfakes.NewFakeSyncer()) require.NoError(t, err) hs.Cfg = setting.NewCfg() @@ -902,7 +903,7 @@ func Test_UpdatePluginSetting(t *testing.T) { t.Run("should return an error when trying to disable an auto-enabled plugin", func(t *testing.T) { server := SetupAPITestServer(t, func(hs *HTTPServer) { - store, err := pluginstore.NewPluginStoreForTest(pluginRegistry, &pluginfakes.FakeLoader{}, &pluginfakes.FakeSourceRegistry{}) + store, err := pluginstore.NewPluginStoreForTest(pluginRegistry, &pluginfakes.FakeLoader{}, &pluginfakes.FakeSourceRegistry{}, installsyncfakes.NewFakeSyncer()) require.NoError(t, err) hs.Cfg = setting.NewCfg() diff --git a/pkg/registry/apps/plugins/register.go b/pkg/registry/apps/plugins/register.go index d2531b50593..dc05a47f965 100644 --- a/pkg/registry/apps/plugins/register.go +++ b/pkg/registry/apps/plugins/register.go @@ -10,14 +10,13 @@ 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" + pluginsappapis "github.com/grafana/grafana/apps/plugins/pkg/apis" + pluginsv0alpha1 "github.com/grafana/grafana/apps/plugins/pkg/apis/plugins/v0alpha1" + pluginsapp "github.com/grafana/grafana/apps/plugins/pkg/app" "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" - - pluginsv0alpha1 "github.com/grafana/grafana/apps/plugins/pkg/apis/plugins/v0alpha1" - pluginsapp "github.com/grafana/grafana/apps/plugins/pkg/app" ) var ( @@ -38,13 +37,13 @@ func RegisterAppInstaller( cfg: cfg, } specificConfig := any(nil) - provider := simple.NewAppProvider(apis.LocalManifest(), specificConfig, pluginsapp.New) + provider := simple.NewAppProvider(pluginsappapis.LocalManifest(), specificConfig, pluginsapp.New) appConfig := app.Config{ KubeConfig: restclient.Config{}, // this will be overridden by the installer's InitializeApp method - ManifestData: *apis.LocalManifest().ManifestData, + ManifestData: *pluginsappapis.LocalManifest().ManifestData, SpecificConfig: specificConfig, } - i, err := appsdkapiserver.NewDefaultAppInstaller(provider, appConfig, &apis.GoTypeAssociator{}) + i, err := appsdkapiserver.NewDefaultAppInstaller(provider, appConfig, pluginsappapis.NewGoTypeAssociator()) if err != nil { return nil, err } diff --git a/pkg/server/wire.go b/pkg/server/wire.go index 8a0fc8623ac..d4a98e8a3c9 100644 --- a/pkg/server/wire.go +++ b/pkg/server/wire.go @@ -123,6 +123,7 @@ import ( plugindashboardsservice "github.com/grafana/grafana/pkg/services/plugindashboards/service" "github.com/grafana/grafana/pkg/services/pluginsintegration" pluginDashboards "github.com/grafana/grafana/pkg/services/pluginsintegration/dashboards" + "github.com/grafana/grafana/pkg/services/pluginsintegration/installsync" "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginaccesscontrol" "github.com/grafana/grafana/pkg/services/preference/prefimpl" promTypeMigration "github.com/grafana/grafana/pkg/services/promtypemigration" @@ -250,6 +251,7 @@ var wireBasicSet = wire.NewSet( httpclientprovider.New, wire.Bind(new(httpclient.Provider), new(*sdkhttpclient.Provider)), serverlock.ProvideService, + wire.Bind(new(installsync.ServerLock), new(*serverlock.ServerLockService)), annotationsimpl.ProvideCleanupService, wire.Bind(new(annotations.Cleaner), new(*annotationsimpl.CleanupServiceImpl)), cleanup.ProvideService, diff --git a/pkg/server/wire_gen.go b/pkg/server/wire_gen.go index 0bdaa1a7c6a..62435221671 100644 --- a/pkg/server/wire_gen.go +++ b/pkg/server/wire_gen.go @@ -173,6 +173,7 @@ import ( "github.com/grafana/grafana/pkg/services/pluginsintegration/angularinspector" "github.com/grafana/grafana/pkg/services/pluginsintegration/angularpatternsstore" "github.com/grafana/grafana/pkg/services/pluginsintegration/dashboards" + "github.com/grafana/grafana/pkg/services/pluginsintegration/installsync" "github.com/grafana/grafana/pkg/services/pluginsintegration/keyretriever" "github.com/grafana/grafana/pkg/services/pluginsintegration/keyretriever/dynamic" "github.com/grafana/grafana/pkg/services/pluginsintegration/keystore" @@ -549,7 +550,12 @@ func Initialize(ctx context.Context, cfg *setting.Cfg, opts Options, apiOpts api } errorRegistry := pluginerrs.ProvideErrorTracker() loaderLoader := loader.ProvideService(pluginManagementCfg, discovery, bootstrap, validate, initialize, terminate, errorRegistry) - pluginstoreService, err := pluginstore.ProvideService(inMemory, sourcesService, loaderLoader, featureToggles) + clientGenerator := apiserver.ProvideClientGenerator(eventualRestConfigProvider) + syncer, err := installsync.ProvideSyncer(featureToggles, clientGenerator, orgService, configProvider, serverLockService) + if err != nil { + return nil, err + } + pluginstoreService, err := pluginstore.ProvideService(inMemory, sourcesService, loaderLoader, syncer, featureToggles) if err != nil { return nil, err } @@ -1159,7 +1165,12 @@ func InitializeForTest(ctx context.Context, t sqlutil.ITestDB, testingT interfac } errorRegistry := pluginerrs.ProvideErrorTracker() loaderLoader := loader.ProvideService(pluginManagementCfg, discovery, bootstrap, validate, initialize, terminate, errorRegistry) - pluginstoreService, err := pluginstore.ProvideService(inMemory, sourcesService, loaderLoader, featureToggles) + clientGenerator := apiserver.ProvideClientGenerator(eventualRestConfigProvider) + syncer, err := installsync.ProvideSyncer(featureToggles, clientGenerator, orgService, configProvider, serverLockService) + if err != nil { + return nil, err + } + pluginstoreService, err := pluginstore.ProvideService(inMemory, sourcesService, loaderLoader, syncer, featureToggles) if err != nil { return nil, err } @@ -1692,7 +1703,7 @@ var withOTelSet = wire.NewSet( otelTracer, grpcserver.ProvideService, interceptors.ProvideAuthenticator, ) -var wireBasicSet = wire.NewSet(annotationsimpl.ProvideService, wire.Bind(new(annotations.Repository), new(*annotationsimpl.RepositoryImpl)), New, api.ProvideHTTPServer, query.ProvideService, wire.Bind(new(query.Service), new(*query.ServiceImpl)), bus.ProvideBus, wire.Bind(new(bus.Bus), new(*bus.InProcBus)), rendering.ProvideService, wire.Bind(new(rendering.Service), new(*rendering.RenderingService)), routing.ProvideRegister, wire.Bind(new(routing.RouteRegister), new(*routing.RouteRegisterImpl)), hooks.ProvideService, kvstore.ProvideService, localcache.ProvideService, bundleregistry.ProvideService, wire.Bind(new(supportbundles.Service), new(*bundleregistry.Service)), updatemanager.ProvideGrafanaService, updatemanager.ProvidePluginsService, service.ProvideService, wire.Bind(new(usagestats.Service), new(*service.UsageStats)), validator3.ProvideService, legacy.ProvideLegacyMigrator, pluginsintegration.WireSet, dashboards.ProvideFileStoreManager, wire.Bind(new(dashboards.FileStore), new(*dashboards.FileStoreManager)), cloudwatch.ProvideService, cloudmonitoring.ProvideService, azuremonitor.ProvideService, postgres.ProvideService, mysql.ProvideService, mssql.ProvideService, store.ProvideEntityEventsService, dualwrite.ProvideService, httpclientprovider.New, wire.Bind(new(httpclient.Provider), new(*httpclient2.Provider)), serverlock.ProvideService, annotationsimpl.ProvideCleanupService, wire.Bind(new(annotations.Cleaner), new(*annotationsimpl.CleanupServiceImpl)), cleanup.ProvideService, shorturlimpl.ProvideService, wire.Bind(new(shorturls.Service), new(*shorturlimpl.ShortURLService)), queryhistory.ProvideService, wire.Bind(new(queryhistory.Service), new(*queryhistory.QueryHistoryService)), correlations.ProvideService, wire.Bind(new(correlations.Service), new(*correlations.CorrelationsService)), quotaimpl.ProvideService, remotecache.ProvideService, wire.Bind(new(remotecache.CacheStorage), new(*remotecache.RemoteCache)), authinfoimpl.ProvideService, wire.Bind(new(login.AuthInfoService), new(*authinfoimpl.Service)), authinfoimpl.ProvideStore, datasourceproxy.ProvideService, sort.ProvideService, search2.ProvideService, searchV2.ProvideService, searchV2.ProvideSearchHTTPService, store.ProvideService, store.ProvideSystemUsersService, live.ProvideService, pushhttp.ProvideService, contexthandler.ProvideService, service12.ProvideService, wire.Bind(new(service12.LDAP), new(*service12.LDAPImpl)), jwt.ProvideService, wire.Bind(new(jwt.JWTService), new(*jwt.AuthService)), store2.ProvideDBStore, image.ProvideDeleteExpiredService, ngalert.ProvideService, librarypanels.ProvideService, wire.Bind(new(librarypanels.Service), new(*librarypanels.LibraryPanelService)), libraryelements.ProvideService, wire.Bind(new(libraryelements.Service), new(*libraryelements.LibraryElementService)), notifications.ProvideService, notifications.ProvideSmtpService, github.ProvideFactory, tracing.ProvideService, tracing.ProvideTracingConfig, wire.Bind(new(tracing.Tracer), new(*tracing.TracingService)), withOTelSet, testdatasource.ProvideService, api4.ProvideService, opentsdb.ProvideService, socialimpl.ProvideService, influxdb.ProvideService, wire.Bind(new(social.Service), new(*socialimpl.SocialService)), tempo.ProvideService, loki.ProvideService, graphite.ProvideService, prometheus.ProvideService, elasticsearch.ProvideService, pyroscope.ProvideService, parca.ProvideService, zipkin.ProvideService, jaeger.ProvideService, service9.ProvideCacheService, wire.Bind(new(datasources.CacheService), new(*service9.CacheServiceImpl)), service2.ProvideEncryptionService, wire.Bind(new(encryption2.Internal), new(*service2.Service)), manager.ProvideSecretsService, wire.Bind(new(secrets.Service), new(*manager.SecretsService)), database.ProvideSecretsStore, wire.Bind(new(secrets.Store), new(*database.SecretsStoreImpl)), garbagecollectionworker.ProvideWorker, grafanads.ProvideService, wire.Bind(new(dashboardsnapshots.Store), new(*database5.DashboardSnapshotStore)), database5.ProvideStore, wire.Bind(new(dashboardsnapshots.Service), new(*service10.ServiceImpl)), service10.ProvideService, service9.ProvideService, wire.Bind(new(datasources.DataSourceService), new(*service9.Service)), service9.ProvideLegacyDataSourceLookup, retriever.ProvideService, wire.Bind(new(serviceaccounts.ServiceAccountRetriever), new(*retriever.Service)), ossaccesscontrol.ProvideServiceAccountPermissions, wire.Bind(new(accesscontrol.ServiceAccountPermissionsService), new(*ossaccesscontrol.ServiceAccountPermissionsService)), manager3.ProvideServiceAccountsService, proxy.ProvideServiceAccountsProxy, wire.Bind(new(serviceaccounts.Service), new(*proxy.ServiceAccountsProxy)), dsquerierclient.NewNullQSDatasourceClientBuilder, expr.ProvideService, featuremgmt.ProvideManagerService, featuremgmt.ProvideToggles, service7.ProvideDashboardServiceImpl, wire.Bind(new(dashboards2.PermissionsRegistrationService), new(*service7.DashboardServiceImpl)), service7.ProvideDashboardService, service7.ProvideDashboardProvisioningService, service7.ProvideDashboardPluginService, database2.ProvideDashboardStore, folderimpl.ProvideService, wire.Bind(new(folder.Service), new(*folderimpl.Service)), wire.Bind(new(folder.LegacyService), new(*folderimpl.Service)), folderimpl.ProvideStore, wire.Bind(new(folder.Store), new(*folderimpl.FolderStoreImpl)), service11.ProvideService, wire.Bind(new(dashboardimport.Service), new(*service11.ImportDashboardService)), service8.ProvideService, wire.Bind(new(plugindashboards.Service), new(*service8.Service)), service8.ProvideDashboardUpdater, kvstore2.ProvideService, avatar.ProvideAvatarCacheServer, statscollector.ProvideService, csrf.ProvideCSRFFilter, wire.Bind(new(csrf.Service), new(*csrf.CSRF)), ossaccesscontrol.ProvideTeamPermissions, wire.Bind(new(accesscontrol.TeamPermissionsService), new(*ossaccesscontrol.TeamPermissionsService)), ossaccesscontrol.ProvideFolderPermissions, wire.Bind(new(accesscontrol.FolderPermissionsService), new(*ossaccesscontrol.FolderPermissionsService)), ossaccesscontrol.ProvideDashboardPermissions, wire.Bind(new(accesscontrol.DashboardPermissionsService), new(*ossaccesscontrol.DashboardPermissionsService)), ossaccesscontrol.ProvideReceiverPermissionsService, wire.Bind(new(accesscontrol.ReceiverPermissionsService), new(*ossaccesscontrol.ReceiverPermissionsService)), starimpl.ProvideService, playlistimpl.ProvideService, apikeyimpl.ProvideService, dashverimpl.ProvideService, service3.ProvideService, wire.Bind(new(publicdashboards.Service), new(*service3.PublicDashboardServiceImpl)), database3.ProvideStore, wire.Bind(new(publicdashboards.Store), new(*database3.PublicDashboardStoreImpl)), metric.ProvideService, api2.ProvideApi, api3.ProvideApi, userimpl.ProvideService, orgimpl.ProvideService, orgimpl.ProvideDeletionService, statsimpl.ProvideService, grpccontext.ProvideContextHandler, grpcserver.ProvideHealthService, grpcserver.ProvideReflectionService, resolver.ProvideEntityReferenceResolver, teamimpl.ProvideService, teamapi.ProvideTeamAPI, tempuserimpl.ProvideService, loginattemptimpl.ProvideService, wire.Bind(new(loginattempt.Service), new(*loginattemptimpl.Service)), migrations2.ProvideDataSourceMigrationService, migrations2.ProvideSecretMigrationProvider, wire.Bind(new(migrations2.SecretMigrationProvider), new(*migrations2.SecretMigrationProviderImpl)), promtypemigration.ProvideAzurePromMigrationService, promtypemigration.ProvideAmazonPromMigrationService, promtypemigration.ProvidePromTypeMigrationProvider, wire.Bind(new(promtypemigration.PromTypeMigrationProvider), new(*promtypemigration.PromTypeMigrationProviderImpl)), resourcepermissions.NewActionSetService, wire.Bind(new(accesscontrol.ActionResolver), new(resourcepermissions.ActionSetService)), wire.Bind(new(pluginaccesscontrol.ActionSetRegistry), new(resourcepermissions.ActionSetService)), permreg.ProvidePermissionRegistry, acimpl.ProvideAccessControl, accesscontrol.ProvideFixedRolesLoader, dualwrite2.ProvideZanzanaReconciler, navtreeimpl.ProvideService, wire.Bind(new(accesscontrol.AccessControl), new(*acimpl.AccessControl)), wire.Bind(new(notifications.TempUserStore), new(tempuser.Service)), tagimpl.ProvideService, wire.Bind(new(tag.Service), new(*tagimpl.Service)), authnimpl.ProvideService, authnimpl.ProvideIdentitySynchronizer, authnimpl.ProvideAuthnService, authnimpl.ProvideAuthnServiceAuthenticateOnly, authnimpl.ProvideRegistration, supportbundlesimpl.ProvideService, extsvcaccounts.ProvideExtSvcAccountsService, wire.Bind(new(serviceaccounts.ExtSvcAccountsService), new(*extsvcaccounts.ExtSvcAccountsService)), registry2.ProvideExtSvcRegistry, wire.Bind(new(extsvcauth.ExternalServiceRegistry), new(*registry2.Registry)), anonstore.ProvideAnonDBStore, wire.Bind(new(anonstore.AnonStore), new(*anonstore.AnonDBStore)), loggermw.Provide, slogadapter.Provide, signingkeysimpl.ProvideEmbeddedSigningKeysService, wire.Bind(new(signingkeys.Service), new(*signingkeysimpl.Service)), ssosettingsimpl.ProvideService, wire.Bind(new(ssosettings.Service), new(*ssosettingsimpl.Service)), idimpl.ProvideService, wire.Bind(new(auth.IDService), new(*idimpl.Service)), cloudmigrationimpl.ProvideService, userimpl.ProvideVerifier, connectors.ProvideOrgRoleMapper, wire.Bind(new(user.Verifier), new(*userimpl.Verifier)), authz.WireSet, metadata.ProvideSecureValueMetadataStorage, metadata.ProvideKeeperMetadataStorage, metadata.ProvideDecryptStorage, decrypt.ProvideDecryptAuthorizer, wire.Value([]decrypt.ExtraOwnerDecrypter(nil)), decrypt.ProvideDecryptService, inline.ProvideInlineSecureValueService, encryption.ProvideDataKeyStorage, encryption.ProvideGlobalDataKeyStorage, encryption.ProvideEncryptedValueStorage, encryption.ProvideGlobalEncryptedValueStorage, service5.ProvideSecureValueService, validator.ProvideKeeperValidator, validator.ProvideSecureValueValidator, mutator.ProvideKeeperMutator, mutator.ProvideSecureValueMutator, migrator2.NewWithEngine, database4.ProvideDatabase, clock.ProvideClock, wire.Bind(new(contracts.Database), new(*database4.Database)), wire.Bind(new(contracts.Clock), new(*clock.Clock)), manager2.ProvideEncryptionManager, service4.ProvideAESGCMCipherService, resource.ProvideStorageMetrics, resource.ProvideIndexMetrics, apiserver.WireSet, apiregistry.WireSet, appregistry.WireSet, client.ProvideK8sClientWithFallback) +var wireBasicSet = wire.NewSet(annotationsimpl.ProvideService, wire.Bind(new(annotations.Repository), new(*annotationsimpl.RepositoryImpl)), New, api.ProvideHTTPServer, query.ProvideService, wire.Bind(new(query.Service), new(*query.ServiceImpl)), bus.ProvideBus, wire.Bind(new(bus.Bus), new(*bus.InProcBus)), rendering.ProvideService, wire.Bind(new(rendering.Service), new(*rendering.RenderingService)), routing.ProvideRegister, wire.Bind(new(routing.RouteRegister), new(*routing.RouteRegisterImpl)), hooks.ProvideService, kvstore.ProvideService, localcache.ProvideService, bundleregistry.ProvideService, wire.Bind(new(supportbundles.Service), new(*bundleregistry.Service)), updatemanager.ProvideGrafanaService, updatemanager.ProvidePluginsService, service.ProvideService, wire.Bind(new(usagestats.Service), new(*service.UsageStats)), validator3.ProvideService, legacy.ProvideLegacyMigrator, pluginsintegration.WireSet, dashboards.ProvideFileStoreManager, wire.Bind(new(dashboards.FileStore), new(*dashboards.FileStoreManager)), cloudwatch.ProvideService, cloudmonitoring.ProvideService, azuremonitor.ProvideService, postgres.ProvideService, mysql.ProvideService, mssql.ProvideService, store.ProvideEntityEventsService, dualwrite.ProvideService, httpclientprovider.New, wire.Bind(new(httpclient.Provider), new(*httpclient2.Provider)), serverlock.ProvideService, wire.Bind(new(installsync.ServerLock), new(*serverlock.ServerLockService)), annotationsimpl.ProvideCleanupService, wire.Bind(new(annotations.Cleaner), new(*annotationsimpl.CleanupServiceImpl)), cleanup.ProvideService, shorturlimpl.ProvideService, wire.Bind(new(shorturls.Service), new(*shorturlimpl.ShortURLService)), queryhistory.ProvideService, wire.Bind(new(queryhistory.Service), new(*queryhistory.QueryHistoryService)), correlations.ProvideService, wire.Bind(new(correlations.Service), new(*correlations.CorrelationsService)), quotaimpl.ProvideService, remotecache.ProvideService, wire.Bind(new(remotecache.CacheStorage), new(*remotecache.RemoteCache)), authinfoimpl.ProvideService, wire.Bind(new(login.AuthInfoService), new(*authinfoimpl.Service)), authinfoimpl.ProvideStore, datasourceproxy.ProvideService, sort.ProvideService, search2.ProvideService, searchV2.ProvideService, searchV2.ProvideSearchHTTPService, store.ProvideService, store.ProvideSystemUsersService, live.ProvideService, pushhttp.ProvideService, contexthandler.ProvideService, service12.ProvideService, wire.Bind(new(service12.LDAP), new(*service12.LDAPImpl)), jwt.ProvideService, wire.Bind(new(jwt.JWTService), new(*jwt.AuthService)), store2.ProvideDBStore, image.ProvideDeleteExpiredService, ngalert.ProvideService, librarypanels.ProvideService, wire.Bind(new(librarypanels.Service), new(*librarypanels.LibraryPanelService)), libraryelements.ProvideService, wire.Bind(new(libraryelements.Service), new(*libraryelements.LibraryElementService)), notifications.ProvideService, notifications.ProvideSmtpService, github.ProvideFactory, tracing.ProvideService, tracing.ProvideTracingConfig, wire.Bind(new(tracing.Tracer), new(*tracing.TracingService)), withOTelSet, testdatasource.ProvideService, api4.ProvideService, opentsdb.ProvideService, socialimpl.ProvideService, influxdb.ProvideService, wire.Bind(new(social.Service), new(*socialimpl.SocialService)), tempo.ProvideService, loki.ProvideService, graphite.ProvideService, prometheus.ProvideService, elasticsearch.ProvideService, pyroscope.ProvideService, parca.ProvideService, zipkin.ProvideService, jaeger.ProvideService, service9.ProvideCacheService, wire.Bind(new(datasources.CacheService), new(*service9.CacheServiceImpl)), service2.ProvideEncryptionService, wire.Bind(new(encryption2.Internal), new(*service2.Service)), manager.ProvideSecretsService, wire.Bind(new(secrets.Service), new(*manager.SecretsService)), database.ProvideSecretsStore, wire.Bind(new(secrets.Store), new(*database.SecretsStoreImpl)), garbagecollectionworker.ProvideWorker, grafanads.ProvideService, wire.Bind(new(dashboardsnapshots.Store), new(*database5.DashboardSnapshotStore)), database5.ProvideStore, wire.Bind(new(dashboardsnapshots.Service), new(*service10.ServiceImpl)), service10.ProvideService, service9.ProvideService, wire.Bind(new(datasources.DataSourceService), new(*service9.Service)), service9.ProvideLegacyDataSourceLookup, retriever.ProvideService, wire.Bind(new(serviceaccounts.ServiceAccountRetriever), new(*retriever.Service)), ossaccesscontrol.ProvideServiceAccountPermissions, wire.Bind(new(accesscontrol.ServiceAccountPermissionsService), new(*ossaccesscontrol.ServiceAccountPermissionsService)), manager3.ProvideServiceAccountsService, proxy.ProvideServiceAccountsProxy, wire.Bind(new(serviceaccounts.Service), new(*proxy.ServiceAccountsProxy)), dsquerierclient.NewNullQSDatasourceClientBuilder, expr.ProvideService, featuremgmt.ProvideManagerService, featuremgmt.ProvideToggles, service7.ProvideDashboardServiceImpl, wire.Bind(new(dashboards2.PermissionsRegistrationService), new(*service7.DashboardServiceImpl)), service7.ProvideDashboardService, service7.ProvideDashboardProvisioningService, service7.ProvideDashboardPluginService, database2.ProvideDashboardStore, folderimpl.ProvideService, wire.Bind(new(folder.Service), new(*folderimpl.Service)), wire.Bind(new(folder.LegacyService), new(*folderimpl.Service)), folderimpl.ProvideStore, wire.Bind(new(folder.Store), new(*folderimpl.FolderStoreImpl)), service11.ProvideService, wire.Bind(new(dashboardimport.Service), new(*service11.ImportDashboardService)), service8.ProvideService, wire.Bind(new(plugindashboards.Service), new(*service8.Service)), service8.ProvideDashboardUpdater, kvstore2.ProvideService, avatar.ProvideAvatarCacheServer, statscollector.ProvideService, csrf.ProvideCSRFFilter, wire.Bind(new(csrf.Service), new(*csrf.CSRF)), ossaccesscontrol.ProvideTeamPermissions, wire.Bind(new(accesscontrol.TeamPermissionsService), new(*ossaccesscontrol.TeamPermissionsService)), ossaccesscontrol.ProvideFolderPermissions, wire.Bind(new(accesscontrol.FolderPermissionsService), new(*ossaccesscontrol.FolderPermissionsService)), ossaccesscontrol.ProvideDashboardPermissions, wire.Bind(new(accesscontrol.DashboardPermissionsService), new(*ossaccesscontrol.DashboardPermissionsService)), ossaccesscontrol.ProvideReceiverPermissionsService, wire.Bind(new(accesscontrol.ReceiverPermissionsService), new(*ossaccesscontrol.ReceiverPermissionsService)), starimpl.ProvideService, playlistimpl.ProvideService, apikeyimpl.ProvideService, dashverimpl.ProvideService, service3.ProvideService, wire.Bind(new(publicdashboards.Service), new(*service3.PublicDashboardServiceImpl)), database3.ProvideStore, wire.Bind(new(publicdashboards.Store), new(*database3.PublicDashboardStoreImpl)), metric.ProvideService, api2.ProvideApi, api3.ProvideApi, userimpl.ProvideService, orgimpl.ProvideService, orgimpl.ProvideDeletionService, statsimpl.ProvideService, grpccontext.ProvideContextHandler, grpcserver.ProvideHealthService, grpcserver.ProvideReflectionService, resolver.ProvideEntityReferenceResolver, teamimpl.ProvideService, teamapi.ProvideTeamAPI, tempuserimpl.ProvideService, loginattemptimpl.ProvideService, wire.Bind(new(loginattempt.Service), new(*loginattemptimpl.Service)), migrations2.ProvideDataSourceMigrationService, migrations2.ProvideSecretMigrationProvider, wire.Bind(new(migrations2.SecretMigrationProvider), new(*migrations2.SecretMigrationProviderImpl)), promtypemigration.ProvideAzurePromMigrationService, promtypemigration.ProvideAmazonPromMigrationService, promtypemigration.ProvidePromTypeMigrationProvider, wire.Bind(new(promtypemigration.PromTypeMigrationProvider), new(*promtypemigration.PromTypeMigrationProviderImpl)), resourcepermissions.NewActionSetService, wire.Bind(new(accesscontrol.ActionResolver), new(resourcepermissions.ActionSetService)), wire.Bind(new(pluginaccesscontrol.ActionSetRegistry), new(resourcepermissions.ActionSetService)), permreg.ProvidePermissionRegistry, acimpl.ProvideAccessControl, accesscontrol.ProvideFixedRolesLoader, dualwrite2.ProvideZanzanaReconciler, navtreeimpl.ProvideService, wire.Bind(new(accesscontrol.AccessControl), new(*acimpl.AccessControl)), wire.Bind(new(notifications.TempUserStore), new(tempuser.Service)), tagimpl.ProvideService, wire.Bind(new(tag.Service), new(*tagimpl.Service)), authnimpl.ProvideService, authnimpl.ProvideIdentitySynchronizer, authnimpl.ProvideAuthnService, authnimpl.ProvideAuthnServiceAuthenticateOnly, authnimpl.ProvideRegistration, supportbundlesimpl.ProvideService, extsvcaccounts.ProvideExtSvcAccountsService, wire.Bind(new(serviceaccounts.ExtSvcAccountsService), new(*extsvcaccounts.ExtSvcAccountsService)), registry2.ProvideExtSvcRegistry, wire.Bind(new(extsvcauth.ExternalServiceRegistry), new(*registry2.Registry)), anonstore.ProvideAnonDBStore, wire.Bind(new(anonstore.AnonStore), new(*anonstore.AnonDBStore)), loggermw.Provide, slogadapter.Provide, signingkeysimpl.ProvideEmbeddedSigningKeysService, wire.Bind(new(signingkeys.Service), new(*signingkeysimpl.Service)), ssosettingsimpl.ProvideService, wire.Bind(new(ssosettings.Service), new(*ssosettingsimpl.Service)), idimpl.ProvideService, wire.Bind(new(auth.IDService), new(*idimpl.Service)), cloudmigrationimpl.ProvideService, userimpl.ProvideVerifier, connectors.ProvideOrgRoleMapper, wire.Bind(new(user.Verifier), new(*userimpl.Verifier)), authz.WireSet, metadata.ProvideSecureValueMetadataStorage, metadata.ProvideKeeperMetadataStorage, metadata.ProvideDecryptStorage, decrypt.ProvideDecryptAuthorizer, wire.Value([]decrypt.ExtraOwnerDecrypter(nil)), decrypt.ProvideDecryptService, inline.ProvideInlineSecureValueService, encryption.ProvideDataKeyStorage, encryption.ProvideGlobalDataKeyStorage, encryption.ProvideEncryptedValueStorage, encryption.ProvideGlobalEncryptedValueStorage, service5.ProvideSecureValueService, validator.ProvideKeeperValidator, validator.ProvideSecureValueValidator, mutator.ProvideKeeperMutator, mutator.ProvideSecureValueMutator, migrator2.NewWithEngine, database4.ProvideDatabase, clock.ProvideClock, wire.Bind(new(contracts.Database), new(*database4.Database)), wire.Bind(new(contracts.Clock), new(*clock.Clock)), manager2.ProvideEncryptionManager, service4.ProvideAESGCMCipherService, resource.ProvideStorageMetrics, resource.ProvideIndexMetrics, apiserver.WireSet, apiregistry.WireSet, appregistry.WireSet, client.ProvideK8sClientWithFallback) var wireSet = wire.NewSet( wireBasicSet, metrics.WireSet, sqlstore.ProvideService, metrics2.ProvideService, wire.Bind(new(notifications.Service), new(*notifications.NotificationService)), wire.Bind(new(notifications.WebhookSender), new(*notifications.NotificationService)), wire.Bind(new(notifications.EmailSender), new(*notifications.NotificationService)), wire.Bind(new(db.DB), new(*sqlstore.SQLStore)), prefimpl.ProvideService, oauthtoken.ProvideService, wire.Bind(new(oauthtoken.OAuthTokenService), new(*oauthtoken.Service)), wire.Bind(new(cleanup.AlertRuleService), new(*store2.DBstore)), diff --git a/pkg/services/apiserver/clientgenerator.go b/pkg/services/apiserver/clientgenerator.go new file mode 100644 index 00000000000..d2ed28b1cbf --- /dev/null +++ b/pkg/services/apiserver/clientgenerator.go @@ -0,0 +1,41 @@ +package apiserver + +import ( + "context" + "sync" + + "github.com/grafana/grafana-app-sdk/k8s" + "github.com/grafana/grafana-app-sdk/resource" +) + +// ProvideClientGenerator creates a lazy-initialized ClientGenerator. +func ProvideClientGenerator(restConfigProvider RestConfigProvider) resource.ClientGenerator { + return &lazyClientGenerator{ + restConfigProvider: restConfigProvider, + } +} + +type lazyClientGenerator struct { + restConfigProvider RestConfigProvider + clientGenerator resource.ClientGenerator + initOnce sync.Once + initError error +} + +func (g *lazyClientGenerator) ClientFor(kind resource.Kind) (resource.Client, error) { + g.initOnce.Do(func() { + restConfig, err := g.restConfigProvider.GetRestConfig(context.Background()) + if err != nil { + g.initError = err + return + } + restConfig.APIPath = "apis" + g.clientGenerator = k8s.NewClientRegistry(*restConfig, k8s.DefaultClientConfig()) + }) + + if g.initError != nil { + return nil, g.initError + } + + return g.clientGenerator.ClientFor(kind) +} diff --git a/pkg/services/apiserver/wireset.go b/pkg/services/apiserver/wireset.go index 7178d092f17..e9c7784fc89 100644 --- a/pkg/services/apiserver/wireset.go +++ b/pkg/services/apiserver/wireset.go @@ -15,4 +15,5 @@ var WireSet = wire.NewSet( ProvideService, wire.Bind(new(Service), new(*service)), wire.Bind(new(builder.APIRegistrar), new(*service)), + ProvideClientGenerator, ) diff --git a/pkg/services/featuremgmt/registry.go b/pkg/services/featuremgmt/registry.go index 2aca483c185..491c1142b3b 100644 --- a/pkg/services/featuremgmt/registry.go +++ b/pkg/services/featuremgmt/registry.go @@ -2091,6 +2091,14 @@ var ( Owner: grafanaPluginsPlatformSquad, Expression: "false", }, + { + Name: "pluginInstallAPISync", + Description: "Enable syncing plugin installations to the installs API", + FrontendOnly: false, + Stage: FeatureStageExperimental, + Owner: grafanaPluginsPlatformSquad, + Expression: "false", + }, { Name: "newGauge", Description: "Enable new gauge visualization", diff --git a/pkg/services/featuremgmt/toggles_gen.csv b/pkg/services/featuremgmt/toggles_gen.csv index 8481c677d09..4b64ee08612 100644 --- a/pkg/services/featuremgmt/toggles_gen.csv +++ b/pkg/services/featuremgmt/toggles_gen.csv @@ -269,6 +269,7 @@ pluginContainers,privatePreview,@grafana/plugins-platform-backend,false,true,fal tempoSearchBackendMigration,GA,@grafana/oss-big-tent,false,true,false cdnPluginsLoadFirst,experimental,@grafana/plugins-platform-backend,false,false,false cdnPluginsUrls,experimental,@grafana/plugins-platform-backend,false,false,false +pluginInstallAPISync,experimental,@grafana/plugins-platform-backend,false,false,false newGauge,experimental,@grafana/dataviz-squad,false,false,true preventPanelChromeOverflow,preview,@grafana/grafana-frontend-platform,false,false,true pluginStoreServiceLoading,experimental,@grafana/plugins-platform-backend,false,false,false diff --git a/pkg/services/featuremgmt/toggles_gen.go b/pkg/services/featuremgmt/toggles_gen.go index dc216663bd4..8b28acc992b 100644 --- a/pkg/services/featuremgmt/toggles_gen.go +++ b/pkg/services/featuremgmt/toggles_gen.go @@ -1086,6 +1086,10 @@ const ( // Enable loading plugins via declarative URLs FlagCdnPluginsUrls = "cdnPluginsUrls" + // FlagPluginInstallAPISync + // Enable syncing plugin installations to the installs API + FlagPluginInstallAPISync = "pluginInstallAPISync" + // FlagNewGauge // Enable new gauge visualization FlagNewGauge = "newGauge" diff --git a/pkg/services/featuremgmt/toggles_gen.json b/pkg/services/featuremgmt/toggles_gen.json index 59a7491cb12..2019ca4b976 100644 --- a/pkg/services/featuremgmt/toggles_gen.json +++ b/pkg/services/featuremgmt/toggles_gen.json @@ -2982,6 +2982,19 @@ "expression": "false" } }, + { + "metadata": { + "name": "pluginInstallAPISync", + "resourceVersion": "1760543624249", + "creationTimestamp": "2025-10-15T15:53:44Z" + }, + "spec": { + "description": "Enable syncing plugin installations to the installs API", + "stage": "experimental", + "codeowner": "@grafana/plugins-platform-backend", + "expression": "false" + } + }, { "metadata": { "name": "pluginProxyPreserveTrailingSlash", diff --git a/pkg/services/pluginsintegration/installsync/installsyncfakes/fakes.go b/pkg/services/pluginsintegration/installsync/installsyncfakes/fakes.go new file mode 100644 index 00000000000..838fefc6e7f --- /dev/null +++ b/pkg/services/pluginsintegration/installsync/installsyncfakes/fakes.go @@ -0,0 +1,26 @@ +package installsyncfakes + +import ( + "context" + + "github.com/grafana/grafana/apps/plugins/pkg/app/install" + "github.com/grafana/grafana/pkg/plugins" + "github.com/grafana/grafana/pkg/services/pluginsintegration/installsync" +) + +var _ installsync.Syncer = &FakeSyncer{} + +type FakeSyncer struct { + SyncFunc func(ctx context.Context, source install.Source, installedPlugins []*plugins.Plugin) error +} + +func NewFakeSyncer() *FakeSyncer { + return &FakeSyncer{} +} + +func (f *FakeSyncer) Sync(ctx context.Context, source install.Source, installedPlugins []*plugins.Plugin) error { + if f.SyncFunc != nil { + return f.SyncFunc(ctx, source, installedPlugins) + } + return nil +} diff --git a/pkg/services/pluginsintegration/installsync/syncer.go b/pkg/services/pluginsintegration/installsync/syncer.go new file mode 100644 index 00000000000..ac6d6ba3d32 --- /dev/null +++ b/pkg/services/pluginsintegration/installsync/syncer.go @@ -0,0 +1,164 @@ +package installsync + +import ( + "context" + "time" + + "github.com/grafana/grafana-app-sdk/resource" + + "github.com/grafana/grafana/apps/plugins/pkg/app/install" + "github.com/grafana/grafana/pkg/configprovider" + "github.com/grafana/grafana/pkg/plugins" + "github.com/grafana/grafana/pkg/services/apiserver/endpoints/request" + "github.com/grafana/grafana/pkg/services/featuremgmt" + "github.com/grafana/grafana/pkg/services/org" +) + +const ( + syncerLockActionName = "plugin-install-api-sync" +) + +var ( + lockTimeout = 10 * time.Minute +) + +// Syncer is the interface for syncing plugin installations to the Kubernetes-style API. +type Syncer interface { + Sync(ctx context.Context, source install.Source, installedPlugins []*plugins.Plugin) error +} + +// ServerLock is the interface for acquiring distributed locks. +type ServerLock interface { + LockExecuteAndRelease(ctx context.Context, actionName string, maxInterval time.Duration, fn func(ctx context.Context)) error +} + +type syncer struct { + featureToggles featuremgmt.FeatureToggles + clientGenerator resource.ClientGenerator + installRegistrar *install.InstallRegistrar + orgService org.Service + namespaceMapper request.NamespaceMapper + serverLock ServerLock +} + +// newSyncer creates a new syncer with the provided dependencies. +func newSyncer( + featureToggles featuremgmt.FeatureToggles, + clientGenerator resource.ClientGenerator, + installRegistrar *install.InstallRegistrar, + orgService org.Service, + namespaceMapper request.NamespaceMapper, + serverLock ServerLock, +) *syncer { + return &syncer{ + clientGenerator: clientGenerator, + featureToggles: featureToggles, + installRegistrar: installRegistrar, + orgService: orgService, + namespaceMapper: namespaceMapper, + serverLock: serverLock, + } +} + +// ProvideSyncer creates a new Syncer for syncing plugin installations to the API. +func ProvideSyncer( + featureToggles featuremgmt.FeatureToggles, + clientGenerator resource.ClientGenerator, + orgService org.Service, + cfgProvider configprovider.ConfigProvider, + serverLock ServerLock, +) (Syncer, error) { + cfg, err := cfgProvider.Get(context.Background()) + if err != nil { + return nil, err + } + installRegistrar := install.NewInstallRegistrar(clientGenerator) + namespaceMapper := request.GetNamespaceMapper(cfg) + + return newSyncer( + featureToggles, + clientGenerator, + installRegistrar, + orgService, + namespaceMapper, + serverLock, + ), nil +} + +func (s *syncer) Sync(ctx context.Context, source install.Source, installedPlugins []*plugins.Plugin) error { + if !s.featureToggles.IsEnabled(ctx, featuremgmt.FlagPluginInstallAPISync) { + return nil + } + + if len(installedPlugins) == 0 { + return nil + } + + var syncErr error + lockErr := s.serverLock.LockExecuteAndRelease(ctx, syncerLockActionName, lockTimeout, func(ctx context.Context) { + syncErr = s.syncAllNamespaces(ctx, source, installedPlugins) + }) + + if lockErr != nil { + return lockErr + } + return syncErr +} + +func (s *syncer) syncAllNamespaces(ctx context.Context, source install.Source, installedPlugins []*plugins.Plugin) error { + orgs, err := s.orgService.Search(ctx, &org.SearchOrgsQuery{}) + if err != nil { + return err + } + + for _, org := range orgs { + err := s.syncNamespace(ctx, s.namespaceMapper(org.ID), source, installedPlugins) + if err != nil { + return err + } + } + + return nil +} + +func (s *syncer) syncNamespace(ctx context.Context, namespace string, source install.Source, installedPlugins []*plugins.Plugin) error { + client, err := s.installRegistrar.GetClient() + if err != nil { + return err + } + + apiPlugins, err := client.ListAll(ctx, namespace, resource.ListOptions{}) + if err != nil { + return err + } + + installedMap := make(map[string]*plugins.Plugin) + for _, p := range installedPlugins { + installedMap[p.ID] = p + } + + // unregister plugins that are not installed + for _, apiPlugin := range apiPlugins.Items { + if _, exists := installedMap[apiPlugin.Spec.Id]; !exists { + err := s.installRegistrar.Unregister(ctx, namespace, apiPlugin.Spec.Id, source) + if err != nil { + return err + } + } + } + + // register plugins that are installed + for _, p := range installedPlugins { + err := s.installRegistrar.Register(ctx, namespace, &install.PluginInstall{ + ID: p.ID, + Version: p.Info.Version, + Class: install.Class(p.Class), + Source: source, + }) + if err != nil { + return err + } + } + + return nil +} diff --git a/pkg/services/pluginsintegration/installsync/syncer_test.go b/pkg/services/pluginsintegration/installsync/syncer_test.go new file mode 100644 index 00000000000..a1ca40638b0 --- /dev/null +++ b/pkg/services/pluginsintegration/installsync/syncer_test.go @@ -0,0 +1,650 @@ +package installsync + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/grafana/grafana-app-sdk/resource" + "github.com/stretchr/testify/require" + errorsK8s "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime/schema" + + pluginsv0alpha1 "github.com/grafana/grafana/apps/plugins/pkg/apis/plugins/v0alpha1" + "github.com/grafana/grafana/apps/plugins/pkg/app/install" + "github.com/grafana/grafana/pkg/plugins" + "github.com/grafana/grafana/pkg/services/featuremgmt" + "github.com/grafana/grafana/pkg/services/org" + "github.com/grafana/grafana/pkg/services/org/orgtest" +) + +// Test helpers to avoid import cycles +type fakeServerLock struct { + lockFunc func(ctx context.Context, actionName string, maxInterval time.Duration, fn func(ctx context.Context)) error +} + +func (f *fakeServerLock) LockExecuteAndRelease(ctx context.Context, actionName string, maxInterval time.Duration, fn func(ctx context.Context)) error { + if f.lockFunc != nil { + return f.lockFunc(ctx, actionName, maxInterval, fn) + } + fn(ctx) + return nil +} + +type fakePluginInstallClient struct { + listAllFunc func(ctx context.Context, namespace string, opts resource.ListOptions) (*pluginsv0alpha1.PluginInstallList, error) + getFunc func(ctx context.Context, identifier resource.Identifier) (*pluginsv0alpha1.PluginInstall, error) + createFunc func(ctx context.Context, obj *pluginsv0alpha1.PluginInstall, opts resource.CreateOptions) (*pluginsv0alpha1.PluginInstall, error) + updateFunc func(ctx context.Context, obj *pluginsv0alpha1.PluginInstall, opts resource.UpdateOptions) (*pluginsv0alpha1.PluginInstall, error) + deleteFunc func(ctx context.Context, identifier resource.Identifier, opts resource.DeleteOptions) error +} + +func (f *fakePluginInstallClient) Get(ctx context.Context, identifier resource.Identifier) (*pluginsv0alpha1.PluginInstall, error) { + if f.getFunc != nil { + return f.getFunc(ctx, identifier) + } + // Return a proper k8s NotFound error + return nil, errorsK8s.NewNotFound(schema.GroupResource{ + Group: pluginsv0alpha1.APIGroup, + Resource: "plugininstalls", + }, identifier.Name) +} + +func (f *fakePluginInstallClient) ListAll(ctx context.Context, namespace string, opts resource.ListOptions) (*pluginsv0alpha1.PluginInstallList, error) { + if f.listAllFunc != nil { + return f.listAllFunc(ctx, namespace, opts) + } + return &pluginsv0alpha1.PluginInstallList{}, nil +} + +func (f *fakePluginInstallClient) List(ctx context.Context, namespace string, opts resource.ListOptions) (*pluginsv0alpha1.PluginInstallList, error) { + return f.ListAll(ctx, namespace, opts) +} + +func (f *fakePluginInstallClient) Create(ctx context.Context, obj *pluginsv0alpha1.PluginInstall, opts resource.CreateOptions) (*pluginsv0alpha1.PluginInstall, error) { + if f.createFunc != nil { + return f.createFunc(ctx, obj, opts) + } + return obj, nil +} + +func (f *fakePluginInstallClient) Update(ctx context.Context, obj *pluginsv0alpha1.PluginInstall, opts resource.UpdateOptions) (*pluginsv0alpha1.PluginInstall, error) { + if f.updateFunc != nil { + return f.updateFunc(ctx, obj, opts) + } + return obj, nil +} + +func (f *fakePluginInstallClient) UpdateStatus(ctx context.Context, identifier resource.Identifier, newStatus pluginsv0alpha1.PluginInstallStatus, opts resource.UpdateOptions) (*pluginsv0alpha1.PluginInstall, error) { + return nil, nil +} + +func (f *fakePluginInstallClient) Patch(ctx context.Context, identifier resource.Identifier, req resource.PatchRequest, opts resource.PatchOptions) (*pluginsv0alpha1.PluginInstall, error) { + return nil, nil +} + +func (f *fakePluginInstallClient) Delete(ctx context.Context, identifier resource.Identifier, opts resource.DeleteOptions) error { + if f.deleteFunc != nil { + return f.deleteFunc(ctx, identifier, opts) + } + return nil +} + +type fakeClientGenerator struct { + client *fakePluginInstallClient +} + +func (f *fakeClientGenerator) ClientFor(kind resource.Kind) (resource.Client, error) { + return &fakeResourceClient{client: f.client}, nil +} + +type fakeResourceClient struct { + client *fakePluginInstallClient +} + +func (f *fakeResourceClient) Get(ctx context.Context, identifier resource.Identifier) (resource.Object, error) { + return f.client.Get(ctx, identifier) +} + +func (f *fakeResourceClient) GetInto(ctx context.Context, identifier resource.Identifier, into resource.Object) error { + obj, err := f.client.Get(ctx, identifier) + if err != nil { + return err + } + // Copy the object data into the provided 'into' object + if target, ok := into.(*pluginsv0alpha1.PluginInstall); ok { + *target = *obj + } + return nil +} + +func (f *fakeResourceClient) List(ctx context.Context, namespace string, options resource.ListOptions) (resource.ListObject, error) { + return f.client.ListAll(ctx, namespace, options) +} + +func (f *fakeResourceClient) ListInto(ctx context.Context, namespace string, options resource.ListOptions, into resource.ListObject) error { + list, err := f.client.ListAll(ctx, namespace, options) + if err != nil { + return err + } + // Copy the list data into the provided 'into' object + if target, ok := into.(*pluginsv0alpha1.PluginInstallList); ok { + *target = *list + } + return nil +} + +func (f *fakeResourceClient) Create(ctx context.Context, identifier resource.Identifier, obj resource.Object, options resource.CreateOptions) (resource.Object, error) { + plugin := obj.(*pluginsv0alpha1.PluginInstall) + return f.client.Create(ctx, plugin, options) +} + +func (f *fakeResourceClient) CreateInto(ctx context.Context, identifier resource.Identifier, obj resource.Object, options resource.CreateOptions, into resource.Object) error { + created, err := f.Create(ctx, identifier, obj, options) + if err != nil { + return err + } + // Copy the created object data into the provided 'into' object + if plugin, ok := created.(*pluginsv0alpha1.PluginInstall); ok { + if target, ok := into.(*pluginsv0alpha1.PluginInstall); ok { + *target = *plugin + } + } + return nil +} + +func (f *fakeResourceClient) Update(ctx context.Context, identifier resource.Identifier, obj resource.Object, options resource.UpdateOptions) (resource.Object, error) { + plugin := obj.(*pluginsv0alpha1.PluginInstall) + return f.client.Update(ctx, plugin, options) +} + +func (f *fakeResourceClient) UpdateInto(ctx context.Context, identifier resource.Identifier, obj resource.Object, options resource.UpdateOptions, into resource.Object) error { + updated, err := f.Update(ctx, identifier, obj, options) + if err != nil { + return err + } + // Copy the updated object data into the provided 'into' object + if plugin, ok := updated.(*pluginsv0alpha1.PluginInstall); ok { + if target, ok := into.(*pluginsv0alpha1.PluginInstall); ok { + *target = *plugin + } + } + return nil +} + +func (f *fakeResourceClient) Patch(ctx context.Context, identifier resource.Identifier, patch resource.PatchRequest, options resource.PatchOptions) (resource.Object, error) { + return f.client.Patch(ctx, identifier, patch, options) +} + +func (f *fakeResourceClient) PatchInto(ctx context.Context, identifier resource.Identifier, patch resource.PatchRequest, options resource.PatchOptions, into resource.Object) error { + patched, err := f.Patch(ctx, identifier, patch, options) + if err != nil { + return err + } + // Copy the patched object data into the provided 'into' object + if plugin, ok := patched.(*pluginsv0alpha1.PluginInstall); ok { + if target, ok := into.(*pluginsv0alpha1.PluginInstall); ok { + *target = *plugin + } + } + return nil +} + +func (f *fakeResourceClient) Delete(ctx context.Context, identifier resource.Identifier, options resource.DeleteOptions) error { + return f.client.Delete(ctx, identifier, options) +} + +func (f *fakeResourceClient) SubresourceRequest(ctx context.Context, identifier resource.Identifier, req resource.CustomRouteRequestOptions) ([]byte, error) { + return []byte{}, nil +} + +func (f *fakeResourceClient) Watch(ctx context.Context, namespace string, options resource.WatchOptions) (resource.WatchResponse, error) { + return &fakeWatchResponse{}, nil +} + +type fakeWatchResponse struct{} + +func (f *fakeWatchResponse) Stop() {} + +func (f *fakeWatchResponse) WatchEvents() <-chan resource.WatchEvent { + ch := make(chan resource.WatchEvent) + close(ch) + return ch +} + +func TestSyncer_Sync(t *testing.T) { + tests := []struct { + name string + featureToggleEnabled bool + orgs []*org.OrgDTO + orgServiceError error + serverLockError error + expectedError error + expectSyncCalls int + }{ + { + name: "feature toggle disabled", + featureToggleEnabled: false, + orgs: []*org.OrgDTO{{ID: 1, Name: "Org 1"}}, + expectedError: nil, + expectSyncCalls: 0, + }, + { + name: "feature toggle enabled, no orgs", + featureToggleEnabled: true, + orgs: []*org.OrgDTO{}, + expectedError: nil, + expectSyncCalls: 0, + }, + { + name: "feature toggle enabled, single org", + featureToggleEnabled: true, + orgs: []*org.OrgDTO{{ID: 1, Name: "Org 1"}}, + expectedError: nil, + expectSyncCalls: 1, + }, + { + name: "feature toggle enabled, multiple orgs", + featureToggleEnabled: true, + orgs: []*org.OrgDTO{ + {ID: 1, Name: "Org 1"}, + {ID: 2, Name: "Org 2"}, + {ID: 3, Name: "Org 3"}, + }, + expectedError: nil, + expectSyncCalls: 3, + }, + { + name: "org service error", + featureToggleEnabled: true, + orgs: nil, + orgServiceError: errors.New("org service error"), + expectedError: errors.New("org service error"), + expectSyncCalls: 0, + }, + { + name: "server lock error", + featureToggleEnabled: true, + orgs: []*org.OrgDTO{{ID: 1, Name: "Org 1"}}, + serverLockError: errors.New("lock error"), + expectedError: errors.New("lock error"), + expectSyncCalls: 0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx := context.Background() + + // Setup feature toggles + ft := featuremgmt.NewMockFeatureToggles(t) + ft.EXPECT().IsEnabled(ctx, featuremgmt.FlagPluginInstallAPISync).Return(tt.featureToggleEnabled).Maybe() + + // Setup org service + orgService := orgtest.NewOrgServiceFake() + orgService.ExpectedOrgs = tt.orgs + orgService.ExpectedError = tt.orgServiceError + + // Setup server lock + serverLock := &fakeServerLock{} + if tt.serverLockError != nil { + serverLock.lockFunc = func(ctx context.Context, actionName string, maxInterval time.Duration, fn func(ctx context.Context)) error { + return tt.serverLockError + } + } + + // Setup fake client and registrar + syncCalls := 0 + fakeClient := &fakePluginInstallClient{ + createFunc: func(ctx context.Context, obj *pluginsv0alpha1.PluginInstall, opts resource.CreateOptions) (*pluginsv0alpha1.PluginInstall, error) { + syncCalls++ + return obj, nil + }, + listAllFunc: func(ctx context.Context, namespace string, opts resource.ListOptions) (*pluginsv0alpha1.PluginInstallList, error) { + return &pluginsv0alpha1.PluginInstallList{}, nil + }, + } + clientGen := &fakeClientGenerator{client: fakeClient} + registrar := install.NewInstallRegistrar(clientGen) + + // Create syncer + s := newSyncer( + ft, + clientGen, + registrar, + orgService, + func(orgID int64) string { return "org-1" }, + serverLock, + ) + + // Execute + installedPlugins := []*plugins.Plugin{ + {JSONData: plugins.JSONData{ID: "test-plugin", Info: plugins.Info{Version: "1.0.0"}}}, + } + err := s.Sync(ctx, install.SourcePluginStore, installedPlugins) + + // Verify + if tt.expectedError != nil { + require.Error(t, err) + require.Equal(t, tt.expectedError.Error(), err.Error()) + } else { + require.NoError(t, err) + } + + require.Equal(t, tt.expectSyncCalls, syncCalls) + }) + } +} + +func TestSyncer_syncNamespace(t *testing.T) { + tests := []struct { + name string + installedPlugins []*plugins.Plugin + apiPlugins []pluginsv0alpha1.PluginInstall + clientListError error + expectedError error + expectedRegCalls int + expectedUnregCalls int + registeredIDs []string + unregisteredIDs []string + }{ + { + name: "no installed plugins, no API plugins", + installedPlugins: []*plugins.Plugin{}, + apiPlugins: []pluginsv0alpha1.PluginInstall{}, + expectedError: nil, + expectedRegCalls: 0, + expectedUnregCalls: 0, + }, + { + name: "installed plugins only", + installedPlugins: []*plugins.Plugin{ + {JSONData: plugins.JSONData{ID: "plugin-1", Info: plugins.Info{Version: "1.0.0"}}, Class: plugins.ClassCore}, + {JSONData: plugins.JSONData{ID: "plugin-2", Info: plugins.Info{Version: "2.0.0"}}, Class: plugins.ClassExternal}, + }, + apiPlugins: []pluginsv0alpha1.PluginInstall{}, + expectedError: nil, + expectedRegCalls: 2, + expectedUnregCalls: 0, + registeredIDs: []string{"plugin-1", "plugin-2"}, + }, + { + name: "API plugins only", + installedPlugins: []*plugins.Plugin{}, + apiPlugins: []pluginsv0alpha1.PluginInstall{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "plugin-1", + Annotations: map[string]string{ + install.PluginInstallSourceAnnotation: install.SourcePluginStore, + }, + }, + Spec: pluginsv0alpha1.PluginInstallSpec{Id: "plugin-1"}, + }, + { + ObjectMeta: metav1.ObjectMeta{ + Name: "plugin-2", + Annotations: map[string]string{ + install.PluginInstallSourceAnnotation: install.SourcePluginStore, + }, + }, + Spec: pluginsv0alpha1.PluginInstallSpec{Id: "plugin-2"}, + }, + }, + expectedError: nil, + expectedRegCalls: 0, + expectedUnregCalls: 2, + unregisteredIDs: []string{"plugin-1", "plugin-2"}, + }, + { + name: "mixed - some match", + installedPlugins: []*plugins.Plugin{ + {JSONData: plugins.JSONData{ID: "plugin-1", Info: plugins.Info{Version: "1.0.0"}}, Class: plugins.ClassCore}, + {JSONData: plugins.JSONData{ID: "plugin-2", Info: plugins.Info{Version: "2.0.0"}}, Class: plugins.ClassExternal}, + {JSONData: plugins.JSONData{ID: "plugin-3", Info: plugins.Info{Version: "3.0.0"}}, Class: plugins.ClassExternal}, + }, + apiPlugins: []pluginsv0alpha1.PluginInstall{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "plugin-2", + Annotations: map[string]string{ + install.PluginInstallSourceAnnotation: install.SourcePluginStore, + }, + }, + Spec: pluginsv0alpha1.PluginInstallSpec{Id: "plugin-2", Version: "2.0.0"}, + }, + { + ObjectMeta: metav1.ObjectMeta{ + Name: "plugin-4", + Annotations: map[string]string{ + install.PluginInstallSourceAnnotation: install.SourcePluginStore, + }, + }, + Spec: pluginsv0alpha1.PluginInstallSpec{Id: "plugin-4"}, + }, + }, + expectedError: nil, + expectedRegCalls: 2, // plugin-1 and plugin-3 are new, plugin-2 already exists + expectedUnregCalls: 1, // plugin-4 removed + registeredIDs: []string{"plugin-1", "plugin-3"}, + unregisteredIDs: []string{"plugin-4"}, + }, + { + name: "list error", + installedPlugins: []*plugins.Plugin{}, + apiPlugins: []pluginsv0alpha1.PluginInstall{}, + clientListError: errors.New("list error"), + expectedError: errors.New("list error"), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx := context.Background() + + // Track calls + var registeredIDs []string + var unregisteredIDs []string + + // Setup fake client + fakeClient := &fakePluginInstallClient{ + listAllFunc: func(ctx context.Context, namespace string, opts resource.ListOptions) (*pluginsv0alpha1.PluginInstallList, error) { + if tt.clientListError != nil { + return nil, tt.clientListError + } + return &pluginsv0alpha1.PluginInstallList{ + Items: tt.apiPlugins, + }, nil + }, + createFunc: func(ctx context.Context, obj *pluginsv0alpha1.PluginInstall, opts resource.CreateOptions) (*pluginsv0alpha1.PluginInstall, error) { + registeredIDs = append(registeredIDs, obj.Spec.Id) + return obj, nil + }, + deleteFunc: func(ctx context.Context, identifier resource.Identifier, opts resource.DeleteOptions) error { + unregisteredIDs = append(unregisteredIDs, identifier.Name) + return nil + }, + getFunc: func(ctx context.Context, identifier resource.Identifier) (*pluginsv0alpha1.PluginInstall, error) { + // Check if plugin exists in apiPlugins + for i := range tt.apiPlugins { + if tt.apiPlugins[i].Name == identifier.Name { + return &tt.apiPlugins[i], nil + } + } + return nil, errorsK8s.NewNotFound(schema.GroupResource{ + Group: pluginsv0alpha1.APIGroup, + Resource: "plugininstalls", + }, identifier.Name) + }, + } + + clientGen := &fakeClientGenerator{client: fakeClient} + registrar := install.NewInstallRegistrar(clientGen) + + // Create syncer + s := newSyncer( + featuremgmt.NewMockFeatureToggles(t), + clientGen, + registrar, + orgtest.NewOrgServiceFake(), + func(orgID int64) string { return "org-1" }, + &fakeServerLock{}, + ) + + // Execute + err := s.syncNamespace(ctx, "org-1", install.SourcePluginStore, tt.installedPlugins) + + // Verify + if tt.expectedError != nil { + require.Error(t, err) + require.Contains(t, err.Error(), tt.expectedError.Error()) + } else { + require.NoError(t, err) + } + + if tt.expectedRegCalls > 0 { + require.Len(t, registeredIDs, tt.expectedRegCalls) + if tt.registeredIDs != nil { + require.ElementsMatch(t, tt.registeredIDs, registeredIDs) + } + } + + if tt.expectedUnregCalls > 0 { + require.Len(t, unregisteredIDs, tt.expectedUnregCalls) + if tt.unregisteredIDs != nil { + require.ElementsMatch(t, tt.unregisteredIDs, unregisteredIDs) + } + } + }) + } +} + +func TestSyncer_getClient(t *testing.T) { + tests := []struct { + name string + }{ + { + name: "first call success and subsequent calls return cached client", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + fakeClient := &fakePluginInstallClient{} + clientGen := &fakeClientGenerator{client: fakeClient} + + s := newSyncer( + featuremgmt.NewMockFeatureToggles(t), + clientGen, + install.NewInstallRegistrar(clientGen), + orgtest.NewOrgServiceFake(), + func(orgID int64) string { return "org-1" }, + &fakeServerLock{}, + ) + + // First call + client1, err1 := s.installRegistrar.GetClient() + require.NoError(t, err1) + require.NotNil(t, client1) + + // Second call should return cached client + client2, err2 := s.installRegistrar.GetClient() + require.NoError(t, err2) + require.NotNil(t, client2) + // Both calls should return the same client instance + require.Equal(t, client1, client2) + }) + } +} + +func TestSyncer_syncAllNamespaces(t *testing.T) { + tests := []struct { + name string + orgs []*org.OrgDTO + orgServiceError error + expectedError error + expectedCalls int + }{ + { + name: "no orgs", + orgs: []*org.OrgDTO{}, + expectedError: nil, + expectedCalls: 0, + }, + { + name: "single org", + orgs: []*org.OrgDTO{ + {ID: 1, Name: "Org 1"}, + }, + expectedError: nil, + expectedCalls: 1, + }, + { + name: "multiple orgs", + orgs: []*org.OrgDTO{ + {ID: 1, Name: "Org 1"}, + {ID: 2, Name: "Org 2"}, + {ID: 3, Name: "Org 3"}, + }, + expectedError: nil, + expectedCalls: 3, + }, + { + name: "org service error", + orgs: nil, + orgServiceError: errors.New("org service error"), + expectedError: errors.New("org service error"), + expectedCalls: 0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx := context.Background() + + orgService := orgtest.NewOrgServiceFake() + orgService.ExpectedOrgs = tt.orgs + orgService.ExpectedError = tt.orgServiceError + + // Track namespace sync calls + syncCalls := 0 + fakeClient := &fakePluginInstallClient{ + createFunc: func(ctx context.Context, obj *pluginsv0alpha1.PluginInstall, opts resource.CreateOptions) (*pluginsv0alpha1.PluginInstall, error) { + syncCalls++ + return obj, nil + }, + listAllFunc: func(ctx context.Context, namespace string, opts resource.ListOptions) (*pluginsv0alpha1.PluginInstallList, error) { + return &pluginsv0alpha1.PluginInstallList{}, nil + }, + } + + clientGen := &fakeClientGenerator{client: fakeClient} + + s := newSyncer( + featuremgmt.NewMockFeatureToggles(t), + clientGen, + install.NewInstallRegistrar(clientGen), + orgService, + func(orgID int64) string { return "org-1" }, + &fakeServerLock{}, + ) + + installedPlugins := []*plugins.Plugin{ + {JSONData: plugins.JSONData{ID: "test-plugin", Info: plugins.Info{Version: "1.0.0"}}, Class: plugins.ClassCore}, + } + + err := s.syncAllNamespaces(ctx, install.SourcePluginStore, installedPlugins) + + if tt.expectedError != nil { + require.Error(t, err) + require.Equal(t, tt.expectedError.Error(), err.Error()) + } else { + require.NoError(t, err) + } + + require.Equal(t, tt.expectedCalls, syncCalls) + }) + } +} diff --git a/pkg/services/pluginsintegration/plugincontext/plugincontext_test.go b/pkg/services/pluginsintegration/plugincontext/plugincontext_test.go index 124534b33c9..b58918da536 100644 --- a/pkg/services/pluginsintegration/plugincontext/plugincontext_test.go +++ b/pkg/services/pluginsintegration/plugincontext/plugincontext_test.go @@ -14,6 +14,7 @@ import ( "github.com/grafana/grafana/pkg/plugins/manager/registry" "github.com/grafana/grafana/pkg/services/datasources" fakeDatasources "github.com/grafana/grafana/pkg/services/datasources/fakes" + "github.com/grafana/grafana/pkg/services/pluginsintegration/installsync/installsyncfakes" "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginconfig" "github.com/grafana/grafana/pkg/services/pluginsintegration/plugincontext" "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginsettings" @@ -41,7 +42,7 @@ func TestGet(t *testing.T) { cfg := setting.NewCfg() ds := &fakeDatasources.FakeDataSourceService{} db := &dbtest.FakeDB{ExpectedError: pluginsettings.ErrPluginSettingNotFound} - store, err := pluginstore.NewPluginStoreForTest(preg, &pluginfakes.FakeLoader{}, &pluginfakes.FakeSourceRegistry{}) + store, err := pluginstore.NewPluginStoreForTest(preg, &pluginfakes.FakeLoader{}, &pluginfakes.FakeSourceRegistry{}, installsyncfakes.NewFakeSyncer()) require.NoError(t, err) pcp := plugincontext.ProvideService(cfg, localcache.ProvideService(), store, &fakeDatasources.FakeCacheService{}, diff --git a/pkg/services/pluginsintegration/plugininstaller/service_test.go b/pkg/services/pluginsintegration/plugininstaller/service_test.go index 52208d4cefa..c832ff06ee5 100644 --- a/pkg/services/pluginsintegration/plugininstaller/service_test.go +++ b/pkg/services/pluginsintegration/plugininstaller/service_test.go @@ -10,6 +10,7 @@ import ( "github.com/grafana/grafana/pkg/plugins/manager/registry" "github.com/grafana/grafana/pkg/plugins/repo" "github.com/grafana/grafana/pkg/services/featuremgmt" + "github.com/grafana/grafana/pkg/services/pluginsintegration/installsync/installsyncfakes" "github.com/grafana/grafana/pkg/services/pluginsintegration/managedplugins" "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginchecker" "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginstore" @@ -26,7 +27,7 @@ func TestService_IsDisabled(t *testing.T) { &setting.Cfg{ PreinstallPluginsAsync: []setting.InstallPlugin{{ID: "myplugin"}}, }, - pluginstore.New(registry.NewInMemory(), &pluginfakes.FakeLoader{}, &pluginfakes.FakeSourceRegistry{}), + pluginstore.New(registry.NewInMemory(), &pluginfakes.FakeLoader{}, &pluginfakes.FakeSourceRegistry{}, installsyncfakes.NewFakeSyncer()), &pluginfakes.FakePluginInstaller{}, prometheus.NewRegistry(), &pluginfakes.FakePluginRepo{}, @@ -160,7 +161,7 @@ func TestService_Run(t *testing.T) { } installed := 0 installedFromURL := 0 - store, err := pluginstore.NewPluginStoreForTest(preg, &pluginfakes.FakeLoader{}, &pluginfakes.FakeSourceRegistry{}) + store, err := pluginstore.NewPluginStoreForTest(preg, &pluginfakes.FakeLoader{}, &pluginfakes.FakeSourceRegistry{}, installsyncfakes.NewFakeSyncer()) require.NoError(t, err) s, err := ProvideService( &setting.Cfg{ diff --git a/pkg/services/pluginsintegration/pluginsintegration.go b/pkg/services/pluginsintegration/pluginsintegration.go index 992eb355a6b..46bc10cb76b 100644 --- a/pkg/services/pluginsintegration/pluginsintegration.go +++ b/pkg/services/pluginsintegration/pluginsintegration.go @@ -38,6 +38,7 @@ import ( "github.com/grafana/grafana/pkg/services/pluginsintegration/angularinspector" "github.com/grafana/grafana/pkg/services/pluginsintegration/angularpatternsstore" "github.com/grafana/grafana/pkg/services/pluginsintegration/clientmiddleware" + "github.com/grafana/grafana/pkg/services/pluginsintegration/installsync" "github.com/grafana/grafana/pkg/services/pluginsintegration/keyretriever" "github.com/grafana/grafana/pkg/services/pluginsintegration/keyretriever/dynamic" "github.com/grafana/grafana/pkg/services/pluginsintegration/keystore" @@ -72,6 +73,7 @@ var WireSet = wire.NewSet( pluginconfig.NewRequestConfigProvider, wire.Bind(new(pluginconfig.PluginRequestConfigProvider), new(*pluginconfig.RequestConfigProvider)), pluginstore.ProvideService, + installsync.ProvideSyncer, wire.Bind(new(pluginstore.Store), new(*pluginstore.Service)), wire.Bind(new(plugins.StaticRouteResolver), new(*pluginstore.Service)), process.ProvideService, diff --git a/pkg/services/pluginsintegration/pluginstore/store.go b/pkg/services/pluginsintegration/pluginstore/store.go index e9cc99d78f5..7c58449249d 100644 --- a/pkg/services/pluginsintegration/pluginstore/store.go +++ b/pkg/services/pluginsintegration/pluginstore/store.go @@ -6,13 +6,16 @@ import ( "time" "github.com/grafana/dskit/services" + "golang.org/x/sync/errgroup" + + "github.com/grafana/grafana/apps/plugins/pkg/app/install" "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/plugins" "github.com/grafana/grafana/pkg/plugins/manager/loader" "github.com/grafana/grafana/pkg/plugins/manager/registry" "github.com/grafana/grafana/pkg/plugins/manager/sources" "github.com/grafana/grafana/pkg/services/featuremgmt" - "golang.org/x/sync/errgroup" + "github.com/grafana/grafana/pkg/services/pluginsintegration/installsync" ) var _ Store = (*Service)(nil) @@ -31,16 +34,17 @@ type Store interface { type Service struct { services.NamedService - pluginRegistry registry.Service - pluginLoader loader.Service - pluginSources sources.Registry - loadOnStartup bool + pluginRegistry registry.Service + pluginLoader loader.Service + pluginSources sources.Registry + installsRegistrar installsync.Syncer + loadOnStartup bool } func ProvideService(pluginRegistry registry.Service, pluginSources sources.Registry, - pluginLoader loader.Service, features featuremgmt.FeatureToggles) (*Service, error) { + pluginLoader loader.Service, installsRegistrar installsync.Syncer, features featuremgmt.FeatureToggles) (*Service, error) { if features.IsEnabledGlobally(featuremgmt.FlagPluginStoreServiceLoading) { - s := New(pluginRegistry, pluginLoader, pluginSources) + s := New(pluginRegistry, pluginLoader, pluginSources, installsRegistrar) s.loadOnStartup = true return s, nil } @@ -51,19 +55,24 @@ func ProvideService(pluginRegistry registry.Service, pluginSources sources.Regis logger := log.New("plugin.store") logger.Info("Loading plugins...") + loadedPluginsToSync := make([]*plugins.Plugin, 0) for _, ps := range pluginSources.List(ctx) { loadedPlugins, err := pluginLoader.Load(ctx, ps) if err != nil { logger.Error("Loading plugin source failed", "source", ps.PluginClass(ctx), "error", err) return nil, err } - + loadedPluginsToSync = append(loadedPluginsToSync, loadedPlugins...) totalPlugins += len(loadedPlugins) } + if err := installsRegistrar.Sync(ctx, install.SourcePluginStore, loadedPluginsToSync); err != nil { + logger.Error("Syncing plugin installations failed", "error", err) + } + logger.Info("Plugins loaded", "count", totalPlugins, "duration", time.Since(start)) - return New(pluginRegistry, pluginLoader, pluginSources), nil + return New(pluginRegistry, pluginLoader, pluginSources, installsRegistrar), nil } func (s *Service) Run(ctx context.Context) error { @@ -74,8 +83,8 @@ func (s *Service) Run(ctx context.Context) error { return s.AwaitTerminated(stopCtx) } -func NewPluginStoreForTest(pluginRegistry registry.Service, pluginLoader loader.Service, pluginSources sources.Registry) (*Service, error) { - s := New(pluginRegistry, pluginLoader, pluginSources) +func NewPluginStoreForTest(pluginRegistry registry.Service, pluginLoader loader.Service, pluginSources sources.Registry, installsRegistrar installsync.Syncer) (*Service, error) { + s := New(pluginRegistry, pluginLoader, pluginSources, installsRegistrar) s.loadOnStartup = true if err := s.StartAsync(context.Background()); err != nil { return nil, err @@ -86,11 +95,12 @@ func NewPluginStoreForTest(pluginRegistry registry.Service, pluginLoader loader. return s, nil } -func New(pluginRegistry registry.Service, pluginLoader loader.Service, pluginSources sources.Registry) *Service { +func New(pluginRegistry registry.Service, pluginLoader loader.Service, pluginSources sources.Registry, installsRegistrar installsync.Syncer) *Service { s := &Service{ - pluginRegistry: pluginRegistry, - pluginLoader: pluginLoader, - pluginSources: pluginSources, + pluginRegistry: pluginRegistry, + pluginLoader: pluginLoader, + pluginSources: pluginSources, + installsRegistrar: installsRegistrar, } s.NamedService = services.NewBasicService(s.starting, s.running, s.stopping).WithName(ServiceName) return s @@ -105,15 +115,21 @@ func (s *Service) starting(ctx context.Context) error { logger := log.New(ServiceName) logger.Info("Loading plugins...") + loadedPluginsToSync := make([]*plugins.Plugin, 0) for _, ps := range s.pluginSources.List(ctx) { loadedPlugins, err := s.pluginLoader.Load(ctx, ps) if err != nil { logger.Error("Loading plugin source failed", "source", ps.PluginClass(ctx), "error", err) return err } + loadedPluginsToSync = append(loadedPluginsToSync, loadedPlugins...) totalPlugins += len(loadedPlugins) } + if err := s.installsRegistrar.Sync(ctx, install.SourcePluginStore, loadedPluginsToSync); err != nil { + logger.Error("Syncing plugin installations failed", "error", err) + } + logger.Info("Plugins loaded", "count", totalPlugins, "duration", time.Since(start)) return nil } diff --git a/pkg/services/pluginsintegration/pluginstore/store_test.go b/pkg/services/pluginsintegration/pluginstore/store_test.go index c3201744096..f90275a084d 100644 --- a/pkg/services/pluginsintegration/pluginstore/store_test.go +++ b/pkg/services/pluginsintegration/pluginstore/store_test.go @@ -7,11 +7,13 @@ import ( "github.com/stretchr/testify/require" + "github.com/grafana/grafana/apps/plugins/pkg/app/install" "github.com/grafana/grafana/pkg/plugins" "github.com/grafana/grafana/pkg/plugins/backendplugin" "github.com/grafana/grafana/pkg/plugins/log" "github.com/grafana/grafana/pkg/plugins/manager/pluginfakes" "github.com/grafana/grafana/pkg/services/featuremgmt" + "github.com/grafana/grafana/pkg/services/pluginsintegration/installsync/installsyncfakes" ) func TestStore_ProvideService(t *testing.T) { @@ -76,7 +78,7 @@ func TestStore_ProvideService(t *testing.T) { features = featuremgmt.WithFeatures() } - service, err := ProvideService(pluginfakes.NewFakePluginRegistry(), srcs, l, features) + service, err := ProvideService(pluginfakes.NewFakePluginRegistry(), srcs, l, installsyncfakes.NewFakeSyncer(), features) require.Equal(t, tt.expectedLoadOnStartup, service.loadOnStartup) require.Equal(t, tt.expectedBeforeStart, loadedSrcs) require.NoError(t, err) @@ -89,6 +91,47 @@ func TestStore_ProvideService(t *testing.T) { }) } }) + + t.Run("Plugin installs are synced", func(t *testing.T) { + registrar := installsyncfakes.NewFakeSyncer() + registered := []*plugins.Plugin{} + registrar.SyncFunc = func(ctx context.Context, source install.Source, installedPlugins []*plugins.Plugin) error { + registered = append(registered, installedPlugins...) + return nil + } + srcs := &pluginfakes.FakeSourceRegistry{ListFunc: func(_ context.Context) []plugins.PluginSource { + return []plugins.PluginSource{ + &pluginfakes.FakePluginSource{ + PluginClassFunc: func(ctx context.Context) plugins.Class { + return plugins.ClassExternal + }, + DiscoverFunc: func(ctx context.Context) ([]*plugins.FoundBundle, error) { + return []*plugins.FoundBundle{ + { + Primary: plugins.FoundPlugin{JSONData: plugins.JSONData{ID: "test-plugin"}}, + }, + }, nil + }, + DefaultSignatureFunc: func(ctx context.Context) (plugins.Signature, bool) { + return plugins.Signature{}, false + }, + }, + } + }} + l := &pluginfakes.FakeLoader{ + LoadFunc: func(ctx context.Context, src plugins.PluginSource) ([]*plugins.Plugin, error) { + return []*plugins.Plugin{{JSONData: plugins.JSONData{ID: "test-plugin"}}}, nil + }, + } + service, err := ProvideService(pluginfakes.NewFakePluginRegistry(), srcs, l, registrar, featuremgmt.WithFeatures()) + require.NoError(t, err) + ctx := context.Background() + err = service.StartAsync(ctx) + require.NoError(t, err) + err = service.AwaitRunning(ctx) + require.NoError(t, err) + require.Len(t, registered, 1) + }) } func TestStore_Plugin(t *testing.T) { @@ -102,7 +145,7 @@ func TestStore_Plugin(t *testing.T) { p1.ID: p1, p2.ID: p2, }, - }, &pluginfakes.FakeLoader{}, &pluginfakes.FakeSourceRegistry{}) + }, &pluginfakes.FakeLoader{}, &pluginfakes.FakeSourceRegistry{}, installsyncfakes.NewFakeSyncer()) require.NoError(t, err) p, exists := ps.Plugin(context.Background(), p1.ID) @@ -132,7 +175,7 @@ func TestStore_Plugins(t *testing.T) { p4.ID: p4, p5.ID: p5, }, - }, &pluginfakes.FakeLoader{}, &pluginfakes.FakeSourceRegistry{}) + }, &pluginfakes.FakeLoader{}, &pluginfakes.FakeSourceRegistry{}, installsyncfakes.NewFakeSyncer()) require.NoError(t, err) ToGrafanaDTO(p1) @@ -176,7 +219,7 @@ func TestStore_Routes(t *testing.T) { p5.ID: p5, p6.ID: p6, }, - }, &pluginfakes.FakeLoader{}, &pluginfakes.FakeSourceRegistry{}) + }, &pluginfakes.FakeLoader{}, &pluginfakes.FakeSourceRegistry{}, installsyncfakes.NewFakeSyncer()) require.NoError(t, err) sr := func(p *plugins.Plugin) *plugins.StaticRoute { @@ -206,7 +249,7 @@ func TestProcessManager_shutdown(t *testing.T) { unloaded = true return nil, nil }, - }, &pluginfakes.FakeSourceRegistry{}) + }, &pluginfakes.FakeSourceRegistry{}, installsyncfakes.NewFakeSyncer()) ctx, cancel := context.WithCancel(context.Background()) @@ -239,7 +282,7 @@ func TestProcessManager_shutdown(t *testing.T) { UnloadFunc: func(_ context.Context, plugin *plugins.Plugin) (*plugins.Plugin, error) { return nil, expectedErr }, - }, &pluginfakes.FakeSourceRegistry{}) + }, &pluginfakes.FakeSourceRegistry{}, installsyncfakes.NewFakeSyncer()) require.NoError(t, err) err = ps.stopping(nil) @@ -259,7 +302,7 @@ func TestStore_availablePlugins(t *testing.T) { p1.ID: p1, p2.ID: p2, }, - }, &pluginfakes.FakeLoader{}, &pluginfakes.FakeSourceRegistry{}) + }, &pluginfakes.FakeLoader{}, &pluginfakes.FakeSourceRegistry{}, installsyncfakes.NewFakeSyncer()) require.NoError(t, err) aps := ps.availablePlugins(context.Background()) diff --git a/pkg/services/pluginsintegration/test_helper.go b/pkg/services/pluginsintegration/test_helper.go index d89f9e393ba..f22a2c29693 100644 --- a/pkg/services/pluginsintegration/test_helper.go +++ b/pkg/services/pluginsintegration/test_helper.go @@ -27,6 +27,7 @@ import ( "github.com/grafana/grafana/pkg/plugins/manager/sources" "github.com/grafana/grafana/pkg/plugins/pluginassets" "github.com/grafana/grafana/pkg/services/featuremgmt" + "github.com/grafana/grafana/pkg/services/pluginsintegration/installsync/installsyncfakes" "github.com/grafana/grafana/pkg/services/pluginsintegration/pipeline" "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginconfig" "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginerrs" @@ -64,7 +65,7 @@ func CreateIntegrationTestCtx(t *testing.T, cfg *setting.Cfg, coreRegistry *core Terminator: term, }) - ps, err := pluginstore.NewPluginStoreForTest(reg, l, sources.ProvideService(cfg, pCfg)) + ps, err := pluginstore.NewPluginStoreForTest(reg, l, sources.ProvideService(cfg, pCfg), installsyncfakes.NewFakeSyncer()) require.NoError(t, err) return &IntegrationTestCtx{