K8s: Expose testdata connection as an api-server (dev mode only) (#79726)
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
Experimental!
|
||||
|
||||
This is exploring how to expose any datasource as a k8s aggregated API server.
|
||||
|
||||
Unlike the other services, this will register other plugins as:
|
||||
|
||||
> {plugin}.datasource.grafana.app
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
package datasource
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"k8s.io/apiserver/pkg/authorization/authorizer"
|
||||
|
||||
"github.com/grafana/grafana/pkg/infra/appcontext"
|
||||
ac "github.com/grafana/grafana/pkg/services/accesscontrol"
|
||||
"github.com/grafana/grafana/pkg/services/datasources"
|
||||
)
|
||||
|
||||
func (b *DataSourceAPIBuilder) GetAuthorizer() authorizer.Authorizer {
|
||||
return authorizer.AuthorizerFunc(
|
||||
func(ctx context.Context, attr authorizer.Attributes) (authorized authorizer.Decision, reason string, err error) {
|
||||
if !attr.IsResourceRequest() {
|
||||
return authorizer.DecisionNoOpinion, "", nil
|
||||
}
|
||||
user, err := appcontext.User(ctx)
|
||||
if err != nil {
|
||||
return authorizer.DecisionDeny, "valid user is required", err
|
||||
}
|
||||
|
||||
uidScope := datasources.ScopeProvider.GetResourceScopeUID(attr.GetName())
|
||||
|
||||
// Must have query access to see a connection
|
||||
if attr.GetResource() == b.connectionResourceInfo.GroupResource().Resource {
|
||||
scopes := []string{}
|
||||
if attr.GetName() != "" {
|
||||
scopes = []string{uidScope}
|
||||
}
|
||||
ok, err := b.accessControl.Evaluate(ctx, user, ac.EvalPermission(datasources.ActionQuery, scopes...))
|
||||
if !ok || err != nil {
|
||||
return authorizer.DecisionDeny, "unable to query", err
|
||||
}
|
||||
|
||||
if attr.GetSubresource() == "proxy" {
|
||||
return authorizer.DecisionDeny, "TODO: map the plugin settings to access rules", err
|
||||
}
|
||||
|
||||
return authorizer.DecisionAllow, "", nil
|
||||
}
|
||||
|
||||
// Must have query access to see a connection
|
||||
action := "" // invalid
|
||||
|
||||
switch attr.GetVerb() {
|
||||
case "list":
|
||||
ok, err := b.accessControl.Evaluate(ctx, user,
|
||||
ac.EvalPermission(datasources.ActionRead)) // Can see any datasource values
|
||||
if !ok || err != nil {
|
||||
return authorizer.DecisionDeny, "unable to read", err
|
||||
}
|
||||
return authorizer.DecisionAllow, "", nil
|
||||
|
||||
case "get":
|
||||
action = datasources.ActionRead
|
||||
case "create":
|
||||
action = datasources.ActionWrite
|
||||
case "post":
|
||||
fallthrough
|
||||
case "update":
|
||||
fallthrough
|
||||
case "patch":
|
||||
fallthrough
|
||||
case "put":
|
||||
action = datasources.ActionWrite
|
||||
case "delete":
|
||||
action = datasources.ActionDelete
|
||||
default:
|
||||
//b.log.Info("unknown verb", "verb", attr.GetVerb())
|
||||
return authorizer.DecisionDeny, "unsupported verb", nil // Unknown verb
|
||||
}
|
||||
ok, err := b.accessControl.Evaluate(ctx, user,
|
||||
ac.EvalPermission(action, uidScope))
|
||||
if !ok || err != nil {
|
||||
return authorizer.DecisionDeny, fmt.Sprintf("unable to %s", action), nil
|
||||
}
|
||||
return authorizer.DecisionAllow, "", nil
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package datasource
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"k8s.io/apimachinery/pkg/apis/meta/internalversion"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/runtime"
|
||||
"k8s.io/apiserver/pkg/registry/rest"
|
||||
|
||||
"github.com/grafana/grafana/pkg/apis"
|
||||
"github.com/grafana/grafana/pkg/apis/datasource/v0alpha1"
|
||||
"github.com/grafana/grafana/pkg/kinds"
|
||||
"github.com/grafana/grafana/pkg/services/datasources"
|
||||
"github.com/grafana/grafana/pkg/services/grafana-apiserver/utils"
|
||||
)
|
||||
|
||||
var (
|
||||
_ rest.Scoper = (*connectionAccess)(nil)
|
||||
_ rest.SingularNameProvider = (*connectionAccess)(nil)
|
||||
_ rest.Getter = (*connectionAccess)(nil)
|
||||
_ rest.Lister = (*connectionAccess)(nil)
|
||||
_ rest.Storage = (*connectionAccess)(nil)
|
||||
)
|
||||
|
||||
type connectionAccess struct {
|
||||
resourceInfo apis.ResourceInfo
|
||||
tableConverter rest.TableConvertor
|
||||
builder *DataSourceAPIBuilder
|
||||
}
|
||||
|
||||
func (s *connectionAccess) New() runtime.Object {
|
||||
return s.resourceInfo.NewFunc()
|
||||
}
|
||||
|
||||
func (s *connectionAccess) Destroy() {}
|
||||
|
||||
func (s *connectionAccess) NamespaceScoped() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func (s *connectionAccess) GetSingularName() string {
|
||||
return s.resourceInfo.GetSingularName()
|
||||
}
|
||||
|
||||
func (s *connectionAccess) ShortNames() []string {
|
||||
return s.resourceInfo.GetShortNames()
|
||||
}
|
||||
|
||||
func (s *connectionAccess) NewList() runtime.Object {
|
||||
return s.resourceInfo.NewListFunc()
|
||||
}
|
||||
|
||||
func (s *connectionAccess) ConvertToTable(ctx context.Context, object runtime.Object, tableOptions runtime.Object) (*metav1.Table, error) {
|
||||
return s.tableConverter.ConvertToTable(ctx, object, tableOptions)
|
||||
}
|
||||
|
||||
func (s *connectionAccess) Get(ctx context.Context, name string, options *metav1.GetOptions) (runtime.Object, error) {
|
||||
ds, err := s.builder.getDataSource(ctx, name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.asConnection(ds), nil
|
||||
}
|
||||
|
||||
func (s *connectionAccess) List(ctx context.Context, options *internalversion.ListOptions) (runtime.Object, error) {
|
||||
result := &v0alpha1.DataSourceConnectionList{
|
||||
Items: []v0alpha1.DataSourceConnection{},
|
||||
}
|
||||
vals, err := s.builder.getDataSources(ctx)
|
||||
if err == nil {
|
||||
for _, ds := range vals {
|
||||
result.Items = append(result.Items, *s.asConnection(ds))
|
||||
}
|
||||
}
|
||||
return result, err
|
||||
}
|
||||
|
||||
func (s *connectionAccess) asConnection(ds *datasources.DataSource) *v0alpha1.DataSourceConnection {
|
||||
v := &v0alpha1.DataSourceConnection{
|
||||
TypeMeta: s.resourceInfo.TypeMeta(),
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: ds.UID,
|
||||
Namespace: s.builder.namespacer(ds.OrgID),
|
||||
CreationTimestamp: metav1.NewTime(ds.Created),
|
||||
ResourceVersion: fmt.Sprintf("%d", ds.Updated.UnixMilli()),
|
||||
},
|
||||
Title: ds.Name,
|
||||
}
|
||||
v.UID = utils.CalculateClusterWideUID(v) // indicates if the value changed on the server
|
||||
meta := kinds.MetaAccessor(v)
|
||||
meta.SetUpdatedTimestamp(&ds.Updated)
|
||||
return v
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package datasource
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/grafana/grafana-plugin-sdk-go/backend"
|
||||
|
||||
"github.com/grafana/grafana/pkg/tsdb/legacydata"
|
||||
)
|
||||
|
||||
// Copied from: https://github.com/grafana/grafana/blob/main/pkg/api/dtos/models.go#L62
|
||||
type rawMetricRequest struct {
|
||||
// From Start time in epoch timestamps in milliseconds or relative using Grafana time units.
|
||||
// required: true
|
||||
// example: now-1h
|
||||
From string `json:"from"`
|
||||
// To End time in epoch timestamps in milliseconds or relative using Grafana time units.
|
||||
// required: true
|
||||
// example: now
|
||||
To string `json:"to"`
|
||||
// queries.refId – Specifies an identifier of the query. Is optional and default to “A”.
|
||||
// queries.datasourceId – Specifies the data source to be queried. Each query in the request must have an unique datasourceId.
|
||||
// queries.maxDataPoints - Species maximum amount of data points that dashboard panel can render. Is optional and default to 100.
|
||||
// queries.intervalMs - Specifies the time interval in milliseconds of time series. Is optional and defaults to 1000.
|
||||
// required: true
|
||||
// example: [ { "refId": "A", "intervalMs": 86400000, "maxDataPoints": 1092, "datasource":{ "uid":"PD8C576611E62080A" }, "rawSql": "SELECT 1 as valueOne, 2 as valueTwo", "format": "table" } ]
|
||||
Queries []rawDataQuery `json:"queries"`
|
||||
// required: false
|
||||
Debug bool `json:"debug"`
|
||||
}
|
||||
|
||||
type rawDataQuery = map[string]interface{}
|
||||
|
||||
func readQueries(in []byte) ([]backend.DataQuery, error) {
|
||||
reqDTO := &rawMetricRequest{}
|
||||
err := json.Unmarshal(in, &reqDTO)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(reqDTO.Queries) == 0 {
|
||||
return nil, fmt.Errorf("expected queries")
|
||||
}
|
||||
|
||||
tr := legacydata.NewDataTimeRange(reqDTO.From, reqDTO.To)
|
||||
backendTr := backend.TimeRange{
|
||||
From: tr.MustGetFrom(),
|
||||
To: tr.MustGetTo(),
|
||||
}
|
||||
queries := make([]backend.DataQuery, 0)
|
||||
|
||||
for _, query := range reqDTO.Queries {
|
||||
dataQuery := backend.DataQuery{
|
||||
TimeRange: backendTr,
|
||||
}
|
||||
|
||||
v, ok := query["refId"]
|
||||
if ok {
|
||||
dataQuery.RefID, ok = v.(string)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("expeted string refId")
|
||||
}
|
||||
}
|
||||
|
||||
v, ok = query["queryType"]
|
||||
if ok {
|
||||
dataQuery.QueryType, ok = v.(string)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("expeted string queryType")
|
||||
}
|
||||
}
|
||||
|
||||
v, ok = query["maxDataPoints"]
|
||||
if ok {
|
||||
vInt, ok := v.(float64)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("expected float64 maxDataPoints")
|
||||
}
|
||||
|
||||
dataQuery.MaxDataPoints = int64(vInt)
|
||||
}
|
||||
|
||||
v, ok = query["intervalMs"]
|
||||
if ok {
|
||||
vInt, ok := v.(float64)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("expected float64 intervalMs")
|
||||
}
|
||||
|
||||
dataQuery.Interval = time.Duration(vInt)
|
||||
}
|
||||
|
||||
dataQuery.JSON, err = json.Marshal(query)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
queries = append(queries, dataQuery)
|
||||
}
|
||||
|
||||
return queries, nil
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package datasource
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestParseQueriesIntoQueryDataRequest(t *testing.T) {
|
||||
request := []byte(`{
|
||||
"queries": [
|
||||
{
|
||||
"refId": "A",
|
||||
"datasource": {
|
||||
"type": "grafana-googlesheets-datasource",
|
||||
"uid": "b1808c48-9fc9-4045-82d7-081781f8a553"
|
||||
},
|
||||
"cacheDurationSeconds": 300,
|
||||
"spreadsheet": "spreadsheetID",
|
||||
"range": "",
|
||||
"datasourceId": 4,
|
||||
"intervalMs": 30000,
|
||||
"maxDataPoints": 794
|
||||
}
|
||||
],
|
||||
"from": "1692624667389",
|
||||
"to": "1692646267389"
|
||||
}`)
|
||||
|
||||
parsedDataQuery, err := readQueries(request)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, len(parsedDataQuery), 1)
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
package datasource
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/grafana/grafana-plugin-sdk-go/backend"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
||||
"k8s.io/apimachinery/pkg/runtime"
|
||||
"k8s.io/apimachinery/pkg/runtime/schema"
|
||||
"k8s.io/apimachinery/pkg/runtime/serializer"
|
||||
"k8s.io/apiserver/pkg/registry/generic"
|
||||
"k8s.io/apiserver/pkg/registry/rest"
|
||||
genericapiserver "k8s.io/apiserver/pkg/server"
|
||||
common "k8s.io/kube-openapi/pkg/common"
|
||||
"k8s.io/utils/strings/slices"
|
||||
|
||||
"github.com/grafana/grafana/pkg/apis"
|
||||
"github.com/grafana/grafana/pkg/apis/datasource/v0alpha1"
|
||||
"github.com/grafana/grafana/pkg/infra/appcontext"
|
||||
"github.com/grafana/grafana/pkg/plugins"
|
||||
"github.com/grafana/grafana/pkg/services/accesscontrol"
|
||||
"github.com/grafana/grafana/pkg/services/datasources"
|
||||
"github.com/grafana/grafana/pkg/services/featuremgmt"
|
||||
grafanaapiserver "github.com/grafana/grafana/pkg/services/grafana-apiserver"
|
||||
"github.com/grafana/grafana/pkg/services/grafana-apiserver/endpoints/request"
|
||||
"github.com/grafana/grafana/pkg/services/grafana-apiserver/utils"
|
||||
"github.com/grafana/grafana/pkg/services/pluginsintegration/pluginstore"
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
)
|
||||
|
||||
var _ grafanaapiserver.APIGroupBuilder = (*DataSourceAPIBuilder)(nil)
|
||||
|
||||
// This is used just so wire has something unique to return
|
||||
type DataSourceAPIBuilder struct {
|
||||
connectionResourceInfo apis.ResourceInfo
|
||||
|
||||
plugin pluginstore.Plugin
|
||||
client plugins.Client
|
||||
dsService datasources.DataSourceService
|
||||
dsCache datasources.CacheService
|
||||
accessControl accesscontrol.AccessControl
|
||||
namespacer request.NamespaceMapper
|
||||
}
|
||||
|
||||
func RegisterAPIService(
|
||||
cfg *setting.Cfg,
|
||||
features featuremgmt.FeatureToggles,
|
||||
apiregistration grafanaapiserver.APIRegistrar,
|
||||
pluginClient plugins.Client,
|
||||
pluginStore pluginstore.Store,
|
||||
dsService datasources.DataSourceService,
|
||||
dsCache datasources.CacheService,
|
||||
accessControl accesscontrol.AccessControl,
|
||||
) (*DataSourceAPIBuilder, error) {
|
||||
// This requires devmode!
|
||||
if !features.IsEnabledGlobally(featuremgmt.FlagGrafanaAPIServerWithExperimentalAPIs) {
|
||||
return nil, nil // skip registration unless opting into experimental apis
|
||||
}
|
||||
|
||||
var err error
|
||||
var builder *DataSourceAPIBuilder
|
||||
all := pluginStore.Plugins(context.Background(), plugins.TypeDataSource)
|
||||
ids := []string{
|
||||
"grafana-testdata-datasource",
|
||||
}
|
||||
|
||||
namespacer := request.GetNamespaceMapper(cfg)
|
||||
for _, ds := range all {
|
||||
if !slices.Contains(ids, ds.ID) {
|
||||
continue // skip this one
|
||||
}
|
||||
|
||||
builder, err = NewDataSourceAPIBuilder(ds, pluginClient, dsService, dsCache, accessControl, namespacer)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
apiregistration.RegisterAPI(builder)
|
||||
}
|
||||
return builder, nil // only used for wire
|
||||
}
|
||||
|
||||
func NewDataSourceAPIBuilder(
|
||||
plugin pluginstore.Plugin,
|
||||
client plugins.Client,
|
||||
dsService datasources.DataSourceService,
|
||||
dsCache datasources.CacheService,
|
||||
accessControl accesscontrol.AccessControl,
|
||||
namespacer request.NamespaceMapper) (*DataSourceAPIBuilder, error) {
|
||||
group, err := getDatasourceGroupNameFromPluginID(plugin.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &DataSourceAPIBuilder{
|
||||
connectionResourceInfo: v0alpha1.GenericConnectionResourceInfo.WithGroupAndShortName(group, plugin.ID+"-connection"),
|
||||
plugin: plugin,
|
||||
client: client,
|
||||
dsService: dsService,
|
||||
dsCache: dsCache,
|
||||
accessControl: accessControl,
|
||||
namespacer: namespacer,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (b *DataSourceAPIBuilder) GetGroupVersion() schema.GroupVersion {
|
||||
return b.connectionResourceInfo.GroupVersion()
|
||||
}
|
||||
|
||||
func addKnownTypes(scheme *runtime.Scheme, gv schema.GroupVersion) {
|
||||
scheme.AddKnownTypes(gv,
|
||||
&v0alpha1.DataSourceConnection{},
|
||||
&v0alpha1.DataSourceConnectionList{},
|
||||
&v0alpha1.HealthCheckResult{},
|
||||
&unstructured.Unstructured{},
|
||||
// Added for subresource stubs
|
||||
&metav1.Status{},
|
||||
)
|
||||
}
|
||||
|
||||
func (b *DataSourceAPIBuilder) InstallSchema(scheme *runtime.Scheme) error {
|
||||
gv := b.connectionResourceInfo.GroupVersion()
|
||||
addKnownTypes(scheme, gv)
|
||||
|
||||
// Link this version to the internal representation.
|
||||
// This is used for server-side-apply (PATCH), and avoids the error:
|
||||
// "no kind is registered for the type"
|
||||
addKnownTypes(scheme, schema.GroupVersion{
|
||||
Group: gv.Group,
|
||||
Version: runtime.APIVersionInternal,
|
||||
})
|
||||
|
||||
// If multiple versions exist, then register conversions from zz_generated.conversion.go
|
||||
// if err := playlist.RegisterConversions(scheme); err != nil {
|
||||
// return err
|
||||
// }
|
||||
metav1.AddToGroupVersion(scheme, gv)
|
||||
return scheme.SetVersionPriority(gv)
|
||||
}
|
||||
|
||||
func (b *DataSourceAPIBuilder) GetAPIGroupInfo(
|
||||
scheme *runtime.Scheme,
|
||||
codecs serializer.CodecFactory, // pointer?
|
||||
optsGetter generic.RESTOptionsGetter,
|
||||
) (*genericapiserver.APIGroupInfo, error) {
|
||||
storage := map[string]rest.Storage{}
|
||||
|
||||
conn := b.connectionResourceInfo
|
||||
storage[conn.StoragePath()] = &connectionAccess{
|
||||
builder: b,
|
||||
resourceInfo: conn,
|
||||
tableConverter: utils.NewTableConverter(
|
||||
conn.GroupResource(),
|
||||
[]metav1.TableColumnDefinition{
|
||||
{Name: "Name", Type: "string", Format: "name"},
|
||||
{Name: "Title", Type: "string", Format: "string", Description: "The datasource title"},
|
||||
{Name: "APIVersion", Type: "string", Format: "string", Description: "API Version"},
|
||||
{Name: "Created At", Type: "date"},
|
||||
},
|
||||
func(obj any) ([]interface{}, error) {
|
||||
m, ok := obj.(*v0alpha1.DataSourceConnection)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("expected connection")
|
||||
}
|
||||
return []interface{}{
|
||||
m.Name,
|
||||
m.Title,
|
||||
m.APIVersion,
|
||||
m.CreationTimestamp.UTC().Format(time.RFC3339),
|
||||
}, nil
|
||||
},
|
||||
),
|
||||
}
|
||||
storage[conn.StoragePath("query")] = &subQueryREST{builder: b}
|
||||
storage[conn.StoragePath("health")] = &subHealthREST{builder: b}
|
||||
|
||||
// TODO! only setup this endpoint if it is implemented
|
||||
storage[conn.StoragePath("resource")] = &subResourceREST{builder: b}
|
||||
|
||||
// Frontend proxy
|
||||
if len(b.plugin.Routes) > 0 {
|
||||
storage[conn.StoragePath("proxy")] = &subProxyREST{builder: b}
|
||||
}
|
||||
|
||||
apiGroupInfo := genericapiserver.NewDefaultAPIGroupInfo(
|
||||
conn.GroupResource().Group, scheme,
|
||||
metav1.ParameterCodec, codecs)
|
||||
|
||||
apiGroupInfo.VersionedResourcesStorageMap[conn.GroupVersion().Version] = storage
|
||||
return &apiGroupInfo, nil
|
||||
}
|
||||
|
||||
func (b *DataSourceAPIBuilder) GetOpenAPIDefinitions() common.GetOpenAPIDefinitions {
|
||||
return v0alpha1.GetOpenAPIDefinitions
|
||||
}
|
||||
|
||||
// Register additional routes with the server
|
||||
func (b *DataSourceAPIBuilder) GetAPIRoutes() *grafanaapiserver.APIRoutes {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *DataSourceAPIBuilder) getDataSourcePluginContext(ctx context.Context, name string) (*backend.PluginContext, error) {
|
||||
info, err := request.NamespaceInfoFrom(ctx, true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
user, err := appcontext.User(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ds, err := b.dsCache.GetDatasourceByUID(ctx, name, user, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
settings := backend.DataSourceInstanceSettings{}
|
||||
settings.ID = ds.ID
|
||||
settings.UID = ds.UID
|
||||
settings.Name = ds.Name
|
||||
settings.URL = ds.URL
|
||||
settings.Updated = ds.Updated
|
||||
settings.User = ds.User
|
||||
settings.JSONData, err = ds.JsonData.ToDB()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
settings.DecryptedSecureJSONData, err = b.dsService.DecryptedValues(ctx, ds)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &backend.PluginContext{
|
||||
OrgID: info.OrgID,
|
||||
PluginID: b.plugin.ID,
|
||||
PluginVersion: b.plugin.Info.Version,
|
||||
User: &backend.User{},
|
||||
AppInstanceSettings: &backend.AppInstanceSettings{},
|
||||
DataSourceInstanceSettings: &settings,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (b *DataSourceAPIBuilder) getDataSource(ctx context.Context, name string) (*datasources.DataSource, error) {
|
||||
user, err := appcontext.User(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return b.dsCache.GetDatasourceByUID(ctx, name, user, false)
|
||||
}
|
||||
|
||||
func (b *DataSourceAPIBuilder) getDataSources(ctx context.Context) ([]*datasources.DataSource, error) {
|
||||
orgId, err := request.OrgIDForList(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return b.dsService.GetDataSourcesByType(ctx, &datasources.GetDataSourcesByTypeQuery{
|
||||
OrgID: orgId,
|
||||
Type: b.plugin.ID,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package datasource
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/grafana/grafana-plugin-sdk-go/backend"
|
||||
"k8s.io/apimachinery/pkg/runtime"
|
||||
"k8s.io/apiserver/pkg/registry/rest"
|
||||
|
||||
"github.com/grafana/grafana/pkg/apis/datasource/v0alpha1"
|
||||
)
|
||||
|
||||
type subHealthREST struct {
|
||||
builder *DataSourceAPIBuilder
|
||||
}
|
||||
|
||||
var _ = rest.Connecter(&subHealthREST{})
|
||||
|
||||
func (r *subHealthREST) New() runtime.Object {
|
||||
return &v0alpha1.HealthCheckResult{}
|
||||
}
|
||||
|
||||
func (r *subHealthREST) Destroy() {
|
||||
}
|
||||
|
||||
func (r *subHealthREST) ConnectMethods() []string {
|
||||
return []string{"GET"}
|
||||
}
|
||||
|
||||
func (r *subHealthREST) NewConnectOptions() (runtime.Object, bool, string) {
|
||||
return nil, false, ""
|
||||
}
|
||||
|
||||
func (r *subHealthREST) Connect(ctx context.Context, name string, opts runtime.Object, responder rest.Responder) (http.Handler, error) {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||
pluginCtx, err := r.builder.getDataSourcePluginContext(ctx, name)
|
||||
if err != nil {
|
||||
responder.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
healthResponse, err := r.builder.client.CheckHealth(ctx, &backend.CheckHealthRequest{
|
||||
PluginContext: *pluginCtx,
|
||||
})
|
||||
if err != nil {
|
||||
responder.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
rsp := &v0alpha1.HealthCheckResult{}
|
||||
rsp.Code = int(healthResponse.Status)
|
||||
rsp.Status = healthResponse.Status.String()
|
||||
rsp.Message = healthResponse.Message
|
||||
|
||||
if len(healthResponse.JSONDetails) > 0 {
|
||||
err = json.Unmarshal(healthResponse.JSONDetails, &rsp.Details)
|
||||
if err != nil {
|
||||
responder.Error(err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
statusCode := http.StatusOK
|
||||
if healthResponse.Status != backend.HealthStatusOk {
|
||||
statusCode = http.StatusBadRequest
|
||||
}
|
||||
responder.Object(statusCode, rsp)
|
||||
}), nil
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package datasource
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/runtime"
|
||||
"k8s.io/apiserver/pkg/registry/rest"
|
||||
)
|
||||
|
||||
type subProxyREST struct {
|
||||
builder *DataSourceAPIBuilder
|
||||
}
|
||||
|
||||
var _ = rest.Connecter(&subProxyREST{})
|
||||
|
||||
func (r *subProxyREST) New() runtime.Object {
|
||||
return &metav1.Status{}
|
||||
}
|
||||
|
||||
func (r *subProxyREST) Destroy() {}
|
||||
|
||||
func (r *subProxyREST) ConnectMethods() []string {
|
||||
unique := map[string]bool{}
|
||||
methods := []string{}
|
||||
for _, r := range r.builder.plugin.Routes {
|
||||
if unique[r.Method] {
|
||||
continue
|
||||
}
|
||||
unique[r.Method] = true
|
||||
methods = append(methods, r.Method)
|
||||
}
|
||||
return methods
|
||||
}
|
||||
|
||||
func (r *subProxyREST) NewConnectOptions() (runtime.Object, bool, string) {
|
||||
return nil, true, ""
|
||||
}
|
||||
|
||||
func (r *subProxyREST) Connect(ctx context.Context, name string, opts runtime.Object, responder rest.Responder) (http.Handler, error) {
|
||||
pluginCtx, err := r.builder.getDataSourcePluginContext(ctx, name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||
responder.Error(fmt.Errorf("TODO, proxy: " + pluginCtx.PluginID))
|
||||
}), nil
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
package datasource
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/grafana/grafana-plugin-sdk-go/backend"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/runtime"
|
||||
"k8s.io/apiserver/pkg/registry/rest"
|
||||
)
|
||||
|
||||
type subQueryREST struct {
|
||||
builder *DataSourceAPIBuilder
|
||||
}
|
||||
|
||||
var _ = rest.Connecter(&subQueryREST{})
|
||||
|
||||
func (r *subQueryREST) New() runtime.Object {
|
||||
return &metav1.Status{}
|
||||
}
|
||||
|
||||
func (r *subQueryREST) Destroy() {
|
||||
}
|
||||
|
||||
func (r *subQueryREST) ConnectMethods() []string {
|
||||
return []string{"POST", "GET"}
|
||||
}
|
||||
|
||||
func (r *subQueryREST) NewConnectOptions() (runtime.Object, bool, string) {
|
||||
return nil, false, ""
|
||||
}
|
||||
|
||||
func (r *subQueryREST) readQueries(req *http.Request) ([]backend.DataQuery, error) {
|
||||
// Simple URL to JSON mapping
|
||||
if req.Method == http.MethodGet {
|
||||
body := make(map[string]any, 0)
|
||||
for k, v := range req.URL.Query() {
|
||||
switch len(v) {
|
||||
case 0:
|
||||
body[k] = true
|
||||
case 1:
|
||||
body[k] = v[0] // TODO, convert numbers
|
||||
default:
|
||||
body[k] = v // TODO, convert numbers
|
||||
}
|
||||
}
|
||||
|
||||
var err error
|
||||
dq := backend.DataQuery{
|
||||
RefID: "A",
|
||||
TimeRange: backend.TimeRange{
|
||||
From: time.Now().Add(-1 * time.Hour), // last hour
|
||||
To: time.Now(),
|
||||
},
|
||||
MaxDataPoints: 1000,
|
||||
Interval: time.Second * 10,
|
||||
}
|
||||
dq.JSON, err = json.Marshal(body)
|
||||
return []backend.DataQuery{dq}, err
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(req.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return readQueries(body)
|
||||
}
|
||||
|
||||
func (r *subQueryREST) Connect(ctx context.Context, name string, opts runtime.Object, responder rest.Responder) (http.Handler, error) {
|
||||
pluginCtx, err := r.builder.getDataSourcePluginContext(ctx, name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||
queries, err := r.readQueries(req)
|
||||
if err != nil {
|
||||
responder.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
queryResponse, err := r.builder.client.QueryData(ctx, &backend.QueryDataRequest{
|
||||
PluginContext: *pluginCtx,
|
||||
Queries: queries,
|
||||
// Headers: // from context
|
||||
})
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
jsonRsp, err := json.Marshal(queryResponse)
|
||||
if err != nil {
|
||||
responder.Error(err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(200)
|
||||
_, _ = w.Write(jsonRsp)
|
||||
}), nil
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package datasource
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/grafana/grafana-plugin-sdk-go/backend"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/runtime"
|
||||
"k8s.io/apiserver/pkg/registry/rest"
|
||||
|
||||
"github.com/grafana/grafana/pkg/plugins/httpresponsesender"
|
||||
)
|
||||
|
||||
type subResourceREST struct {
|
||||
builder *DataSourceAPIBuilder
|
||||
}
|
||||
|
||||
var _ = rest.Connecter(&subResourceREST{})
|
||||
|
||||
func (r *subResourceREST) New() runtime.Object {
|
||||
return &metav1.Status{}
|
||||
}
|
||||
|
||||
func (r *subResourceREST) Destroy() {
|
||||
}
|
||||
|
||||
func (r *subResourceREST) ConnectMethods() []string {
|
||||
// All for now??? ideally we have a schema for resource and limit this
|
||||
return []string{
|
||||
http.MethodGet,
|
||||
http.MethodHead,
|
||||
http.MethodPost,
|
||||
http.MethodPut,
|
||||
http.MethodPatch,
|
||||
http.MethodDelete,
|
||||
http.MethodOptions,
|
||||
}
|
||||
}
|
||||
|
||||
func (r *subResourceREST) NewConnectOptions() (runtime.Object, bool, string) {
|
||||
return nil, true, ""
|
||||
}
|
||||
|
||||
func (r *subResourceREST) Connect(ctx context.Context, name string, opts runtime.Object, responder rest.Responder) (http.Handler, error) {
|
||||
pluginCtx, err := r.builder.getDataSourcePluginContext(ctx, name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||
body, err := io.ReadAll(req.Body)
|
||||
if err != nil {
|
||||
responder.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
idx := strings.LastIndex(req.URL.Path, "/resource")
|
||||
if idx < 0 {
|
||||
responder.Error(fmt.Errorf("expected resource path")) // 400?
|
||||
return
|
||||
}
|
||||
|
||||
path := req.URL.Path[idx+len("/resource"):]
|
||||
err = r.builder.client.CallResource(ctx, &backend.CallResourceRequest{
|
||||
PluginContext: *pluginCtx,
|
||||
Path: path,
|
||||
Method: req.Method,
|
||||
Body: body,
|
||||
}, httpresponsesender.New(w))
|
||||
|
||||
if err != nil {
|
||||
responder.Error(err)
|
||||
}
|
||||
}), nil
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package datasource
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func getDatasourceGroupNameFromPluginID(pluginId string) (string, error) {
|
||||
if pluginId == "" {
|
||||
return "", fmt.Errorf("bad pluginID (empty)")
|
||||
}
|
||||
parts := strings.Split(pluginId, "-")
|
||||
if len(parts) == 1 {
|
||||
return fmt.Sprintf("%s.datasource.grafana.app", parts[0]), nil
|
||||
}
|
||||
|
||||
last := parts[len(parts)-1]
|
||||
if last != "datasource" {
|
||||
return "", fmt.Errorf("bad pluginID (%s)", pluginId)
|
||||
}
|
||||
if parts[0] == "grafana" {
|
||||
parts = parts[1:] // strip the first value
|
||||
}
|
||||
return fmt.Sprintf("%s.datasource.grafana.app", strings.Join(parts[:len(parts)-1], "-")), nil
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package datasource
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestUtils(t *testing.T) {
|
||||
// multiple flavors of the same idea
|
||||
require.Equal(t, "tempo.datasource.grafana.app", getIDIgnoreError("tempo"))
|
||||
require.Equal(t, "tempo.datasource.grafana.app", getIDIgnoreError("grafana-tempo-datasource"))
|
||||
require.Equal(t, "tempo.datasource.grafana.app", getIDIgnoreError("tempo-datasource"))
|
||||
|
||||
// Multiple dashes in the name
|
||||
require.Equal(t, "org-name.datasource.grafana.app", getIDIgnoreError("org-name-datasource"))
|
||||
require.Equal(t, "org-name-more.datasource.grafana.app", getIDIgnoreError("org-name-more-datasource"))
|
||||
require.Equal(t, "org-name-more-more.datasource.grafana.app", getIDIgnoreError("org-name-more-more-datasource"))
|
||||
|
||||
require.Error(t, getErrorIgnoreValue("graph-panel"))
|
||||
require.Error(t, getErrorIgnoreValue("anything-notdatasource"))
|
||||
}
|
||||
|
||||
func getIDIgnoreError(id string) string {
|
||||
v, _ := getDatasourceGroupNameFromPluginID(id)
|
||||
return v
|
||||
}
|
||||
|
||||
func getErrorIgnoreValue(id string) error {
|
||||
_, err := getDatasourceGroupNameFromPluginID(id)
|
||||
return err
|
||||
}
|
||||
Reference in New Issue
Block a user