Repository: grafana/grafana-plugin-sdk-go (go)

Issue:
Supporting managed app plugins
**This is probably no a bug. Describing the situation below and re can re-categorize**

**What happened**:
Was working on a Grafana plugin that needed both a backend and frontend plugin, but was an **app plugin**. I tried to follow the managed backend approach, which allows me to access the `jsonData` and `secredJsonData` upon instance creation. For this, I followed examples like [this one](https://github.com/grafana/grafana-iot-twinmaker-app/blob/67a389a860205eea0d16e8b921d9fbae0878ea02/pkg/main.go#L12), even though I knew my plugin wasn't a datasource one.

Since this is not a `datasource` plugin, each `CallResourceRequest` incoming from the frontend will include `AppInstanceSettings` instead of `DatasourceInstanceSettings`. That means that generating the instance key will fail with [this](https://github.com/grafana/grafana-plugin-sdk-go/blob/3f3bfe95595cb3d4b3fafc7b9e4a36cd90f5ba69/backend/datasource/instance_provider.go#L45) error.

**What you expected to happen**:
Everything to work as expected.

**How to reproduce it (as minimally and precisely as possible)**:
Create an app plugin, and try to fit it inside the `datasource.Manage` approach.

**Anything else we need to know?**:

First tried to make the `datasource.InstanceProvider` consider the `AppInstanceSettings` as well, but the logic is more coupled for datasource stuff. Maybe the way to go is doing something like this.

https://github.com/grafana/grafana-plugin-sdk-go/compare/pablo/add-support-app-plugins?expand=1

**Environment**:
- SDK version:
- Grafana version:
- Go version:


Pull request description:
Add support for managed app plugins
<!--

Thank you for sending a pull request! Here are some tips:

1. If this is your first time, please read our contribution guide at https://github.com/grafana/grafana-plugin-sdk-go/blob/master/CONTRIBUTING.md

2. Ensure you include and run the appropriate tests as part of your Pull Request.

3. If the Pull Request is a work in progress, make use of GitHub's "Draft PR" feature and mark it as such.

4. If you can not merge your Pull Request due to a merge conflict, Rebase it. This gets it in sync with the master branch.

-->

**What this PR does / why we need it**:
The SDK introduces to plugin development a way to initialize the backend plugin instances with configurations contained in the plugin's `jsonData` or `securedJsonData`. This is achieved by having _managed_ backend plugin instances.

Currently, just datasource plugins are supported for this managed approach.

This PR is based on the datasource work, and does the same for app plugins, which should have just one plugin instance alive at each moment.

**Which issue(s) this PR fixes**:
Fixes #534

**Special notes for your reviewer**:
- ~~Tests haven't been added yet. Would be happy to add if the implemented approach is accepted.~~


Patch:
diff --git a/backend/app/doc.go b/backend/app/doc.go
new file mode 100644
index 0000000..515a32f
--- /dev/null
+++ b/backend/app/doc.go
@@ -0,0 +1,2 @@
+// Package app provides utilities for creating and serving a app plugin over gRPC.
+package app
diff --git a/backend/app/instance_provider.go b/backend/app/instance_provider.go
new file mode 100644
index 0000000..7b47808
--- /dev/null
+++ b/backend/app/instance_provider.go
@@ -0,0 +1,62 @@
+package app
+
+import (
+	"fmt"
+
+	"github.com/grafana/grafana-plugin-sdk-go/backend"
+	"github.com/grafana/grafana-plugin-sdk-go/backend/instancemgmt"
+)
+
+// InstanceFactoryFunc factory method for creating app instances.
+type InstanceFactoryFunc func(settings backend.AppInstanceSettings) (instancemgmt.Instance, error)
+
+// NewInstanceManager creates a new app instance manager,
+//
+// This is a helper method for calling NewInstanceProvider and creating a new instancemgmt.InstanceProvider,
+// and providing that to instancemgmt.New.
+func NewInstanceManager(fn InstanceFactoryFunc) instancemgmt.InstanceManager {
+	ip := NewInstanceProvider(fn)
+	return instancemgmt.New(ip)
+}
+
+// NewInstanceProvider create a new app instance provider,
+//
+// The instance provider is responsible for providing cache keys for application instances,
+// creating new instances when needed and invalidating cached instances when they have been
+// updated in Grafana.
+// Cache key is based on the app plugin identifier, and the numeric Grafana organization ID.
+// If fn is nil, NewInstanceProvider panics.
+func NewInstanceProvider(fn InstanceFactoryFunc) instancemgmt.InstanceProvider {
+	if fn == nil {
+		panic("fn cannot be nil")
+	}
+
+	return &instanceProvider{
+		factory: fn,
+	}
+}
+
+type instanceProvider struct {
+	factory InstanceFactoryFunc
+}
+
+func (ip *instanceProvider) GetKey(pluginContext backend.PluginContext) (interface{}, error) {
+	if pluginContext.AppInstanceSettings == nil {
+		// fail fast if there is no app settings
+		return nil, fmt.Errorf("app instance settings cannot be nil")
+	}
+
+	// The instance key generated for app plugins should include both plugin ID, and the OrgID, since for a single
+	// Grafana instance there might be different orgs using the same plugin.
+	return fmt.Sprintf("%s#%v", pluginContext.PluginID, pluginContext.OrgID), nil
+}
+
+func (ip *instanceProvider) NeedsUpdate(pluginContext backend.PluginContext, cachedInstance instancemgmt.CachedInstance) bool {
+	curSettings := pluginContext.AppInstanceSettings
+	cachedSettings := cachedInstance.PluginContext.AppInstanceSettings
+	return !curSettings.Updated.Equal(cachedSettings.Updated)
+}
+
+func (ip *instanceProvider) NewInstance(pluginContext backend.PluginContext) (instancemgmt.Instance, error) {
+	return ip.factory(*pluginContext.AppInstanceSettings)
+}
diff --git a/backend/app/manage.go b/backend/app/manage.go
new file mode 100644
index 0000000..37ee360
--- /dev/null
+++ b/backend/app/manage.go
@@ -0,0 +1,44 @@
+package app
+
+import (
+	"github.com/grafana/grafana-plugin-sdk-go/backend"
+	"github.com/grafana/grafana-plugin-sdk-go/internal/automanagement"
+	"github.com/grafana/grafana-plugin-sdk-go/internal/standalone"
+)
+
+// ManageOpts can modify Manage behaviour.
+type ManageOpts struct {
+	// GRPCSettings settings for gPRC.
+	GRPCSettings backend.GRPCSettings
+}
+
+// Manage starts serving the app over gPRC with automatic instance management.
+// pluginID should match the one from plugin.json.
+func Manage(pluginID string, instanceFactory InstanceFactoryFunc, opts ManageOpts) error {
+	backend.SetupPluginEnvironment(pluginID) // Enable profiler.
+
+	handler := automanagement.NewManager(NewInstanceManager(instanceFactory))
+
+	serveOpts := backend.ServeOpts{
+		CheckHealthHandler:  handler,
+		CallResourceHandler: handler,
+		QueryDataHandler:    handler,
+		StreamHandler:       handler,
+		GRPCSettings:        opts.GRPCSettings,
+	}
+
+	info, err := standalone.GetInfo(pluginID)
+	if err != nil {
+		return err
+	}
+
+	if info.Standalone {
+		return backend.StandaloneServe(serveOpts, info.Address)
+	} else if info.Address != "" {
+		standalone.RunDummyPluginLocator(info.Address)
+		return nil
+	}
+
+	// The default/normal hashicorp path.
+	return backend.Serve(serveOpts)
+}
diff --git a/build/common.go b/build/common.go
index 15176df..a443cbf 100644
--- a/build/common.go
+++ b/build/common.go
@@ -174,6 +174,14 @@ func (Build) Debug() error {
 
 // Backend build a production build for the current platform
 func (Build) Backend() error {
+	// The M1 platform detection is kinda flakey, so we will just build both
+	if runtime.GOOS == "darwin" {
+		err := buildBackend(newBuildConfig("darwin", "arm64"))
+		if err != nil {
+			return err
+		}
+		return buildBackend(newBuildConfig("darwin", "amd64"))
+	}
 	cfg := newBuildConfig(runtime.GOOS, runtime.GOARCH)
 	return buildBackend(cfg)
 }


Test patch:
diff --git a/backend/app/instance_provider_test.go b/backend/app/instance_provider_test.go
new file mode 100644
index 0000000..5a7440b
--- /dev/null
+++ b/backend/app/instance_provider_test.go
@@ -0,0 +1,84 @@
+package app
+
+import (
+	"testing"
+	"time"
+
+	"github.com/grafana/grafana-plugin-sdk-go/backend"
+	"github.com/grafana/grafana-plugin-sdk-go/backend/instancemgmt"
+	"github.com/stretchr/testify/require"
+)
+
+const (
+	testAppPluginID = "super-app-plugin"
+	testOrgID       = 42
+)
+
+func TestInstanceProvider(t *testing.T) {
+	type testInstance struct {
+		value string
+	}
+	ip := NewInstanceProvider(func(settings backend.AppInstanceSettings) (instancemgmt.Instance, error) {
+		return testInstance{value: "what an app"}, nil
+	})
+
+	t.Run("When data source instance settings not provided should return error", func(t *testing.T) {
+		_, err := ip.GetKey(backend.PluginContext{})
+		require.Error(t, err)
+	})
+
+	t.Run("When app instance settings provided should return expected key", func(t *testing.T) {
+		key, err := ip.GetKey(backend.PluginContext{
+			PluginID:            testAppPluginID,
+			OrgID:               testOrgID,
+			AppInstanceSettings: &backend.AppInstanceSettings{},
+		})
+		require.NoError(t, err)
+		require.Equal(t, "super-app-plugin#42", key)
+	})
+
+	t.Run("When current app instance settings compared to cached instance haven't been updated should return false", func(t *testing.T) {
+		curSettings := backend.PluginContext{
+			AppInstanceSettings: &backend.AppInstanceSettings{
+				Updated: time.Now(),
+			},
+		}
+		cachedInstance := instancemgmt.CachedInstance{
+			PluginContext: backend.PluginContext{
+				AppInstanceSettings: &backend.AppInstanceSettings{
+					Updated: curSettings.AppInstanceSettings.Updated,
+				},
+			},
+		}
+		needsUpdate := ip.NeedsUpdate(curSettings, cachedInstance)
+		require.False(t, needsUpdate)
+	})
+
+	t.Run("When current app instance settings compared to cached instance have been updated should return true", func(t *testing.T) {
+		curSettings := backend.PluginContext{
+			AppInstanceSettings: &backend.AppInstanceSettings{
+				Updated: time.Now(),
+			},
+		}
+		cachedInstance := instancemgmt.CachedInstance{
+			PluginContext: backend.PluginContext{
+				AppInstanceSettings: &backend.AppInstanceSettings{
+					Updated: curSettings.AppInstanceSettings.Updated.Add(time.Second),
+				},
+			},
+		}
+		needsUpdate := ip.NeedsUpdate(curSettings, cachedInstance)
+		require.True(t, needsUpdate)
+	})
+
+	t.Run("When creating a new instance should return expected instance", func(t *testing.T) {
+		i, err := ip.NewInstance(backend.PluginContext{
+			PluginID:            testAppPluginID,
+			OrgID:               testOrgID,
+			AppInstanceSettings: &backend.AppInstanceSettings{},
+		})
+		require.NoError(t, err)
+		require.NotNil(t, i)
+		require.Equal(t, "what an app", i.(testInstance).value)
+	})
+}