Fetch datasource config in the Grafana UI via app platform endpoints

When `config.featureToggles.queryServiceWithConnections` is true, calls
to `getDataSourceByUid` are routed through a new frontend function,
`getDataSourceFromK8sAPI`, which exercises the new app platform
kubernetes style APIs.
This commit is contained in:
beejeebus 2026-01-08 18:57:42 +00:00
parent 8f8e076700
commit 089176bf23
9 changed files with 508 additions and 161 deletions

View file

@ -4,6 +4,7 @@ import (
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
common "github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1"
"github.com/grafana/grafana/pkg/services/accesscontrol"
)
// +k8s:deepcopy-gen=true
@ -24,3 +25,11 @@ type HealthCheckResult struct {
// Spec depends on the plugin
Details *common.Unstructured `json:"details,omitempty"`
}
// +k8s:deepcopy-gen=true
// +k8s:openapi-gen=true
// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object
type DatasourceAccessInfo struct {
metav1.TypeMeta `json:",inline"`
Permissions accesscontrol.Metadata
}

View file

@ -9,6 +9,7 @@ package v0alpha1
import (
commonv0alpha1 "github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1"
accesscontrol "github.com/grafana/grafana/pkg/services/accesscontrol"
runtime "k8s.io/apimachinery/pkg/runtime"
)
@ -79,6 +80,38 @@ func (in *DataSourceList) DeepCopyObject() runtime.Object {
return nil
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *DatasourceAccessInfo) DeepCopyInto(out *DatasourceAccessInfo) {
*out = *in
out.TypeMeta = in.TypeMeta
if in.Permissions != nil {
in, out := &in.Permissions, &out.Permissions
*out = make(accesscontrol.Metadata, len(*in))
for key, val := range *in {
(*out)[key] = val
}
}
return
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DatasourceAccessInfo.
func (in *DatasourceAccessInfo) DeepCopy() *DatasourceAccessInfo {
if in == nil {
return nil
}
out := new(DatasourceAccessInfo)
in.DeepCopyInto(out)
return out
}
// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
func (in *DatasourceAccessInfo) DeepCopyObject() runtime.Object {
if c := in.DeepCopy(); c != nil {
return c
}
return nil
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *HealthCheckResult) DeepCopyInto(out *HealthCheckResult) {
*out = *in

View file

@ -16,6 +16,7 @@ func GetOpenAPIDefinitions(ref common.ReferenceCallback) map[string]common.OpenA
return map[string]common.OpenAPIDefinition{
"github.com/grafana/grafana/pkg/apis/datasource/v0alpha1.DataSource": schema_pkg_apis_datasource_v0alpha1_DataSource(ref),
"github.com/grafana/grafana/pkg/apis/datasource/v0alpha1.DataSourceList": schema_pkg_apis_datasource_v0alpha1_DataSourceList(ref),
"github.com/grafana/grafana/pkg/apis/datasource/v0alpha1.DatasourceAccessInfo": schema_pkg_apis_datasource_v0alpha1_DatasourceAccessInfo(ref),
"github.com/grafana/grafana/pkg/apis/datasource/v0alpha1.GenericDataSourceSpec": schema_pkg_apis_datasource_v0alpha1_GenericDataSourceSpec(ref),
"github.com/grafana/grafana/pkg/apis/datasource/v0alpha1.HealthCheckResult": schema_pkg_apis_datasource_v0alpha1_HealthCheckResult(ref),
"github.com/grafana/grafana/pkg/apis/datasource/v0alpha1.UnstructuredSpec": UnstructuredSpec{}.OpenAPIDefinition(),
@ -126,6 +127,48 @@ func schema_pkg_apis_datasource_v0alpha1_DataSourceList(ref common.ReferenceCall
}
}
func schema_pkg_apis_datasource_v0alpha1_DatasourceAccessInfo(ref common.ReferenceCallback) common.OpenAPIDefinition {
return common.OpenAPIDefinition{
Schema: spec.Schema{
SchemaProps: spec.SchemaProps{
Type: []string{"object"},
Properties: map[string]spec.Schema{
"kind": {
SchemaProps: spec.SchemaProps{
Description: "Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds",
Type: []string{"string"},
Format: "",
},
},
"apiVersion": {
SchemaProps: spec.SchemaProps{
Description: "APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources",
Type: []string{"string"},
Format: "",
},
},
"Permissions": {
SchemaProps: spec.SchemaProps{
Type: []string{"object"},
AdditionalProperties: &spec.SchemaOrBool{
Allows: true,
Schema: &spec.Schema{
SchemaProps: spec.SchemaProps{
Default: false,
Type: []string{"boolean"},
Format: "",
},
},
},
},
},
},
Required: []string{"Permissions"},
},
},
}
}
func schema_pkg_apis_datasource_v0alpha1_GenericDataSourceSpec(ref common.ReferenceCallback) common.OpenAPIDefinition {
return common.OpenAPIDefinition{
Schema: spec.Schema{

View file

@ -1,2 +1,3 @@
API rule violation: names_match,github.com/grafana/grafana/pkg/apis/datasource/v0alpha1,DatasourceAccessInfo,Permissions
API rule violation: names_match,github.com/grafana/grafana/pkg/apis/datasource/v0alpha1,UnstructuredSpec,Object
API rule violation: streaming_list_type_json_tags,github.com/grafana/grafana/pkg/apis/datasource/v0alpha1,DataSourceList,ListMeta

View file

@ -191,6 +191,7 @@ func addKnownTypes(scheme *runtime.Scheme, gv schema.GroupVersion) {
&datasourceV0.DataSourceList{},
&datasourceV0.HealthCheckResult{},
&unstructured.Unstructured{},
&datasourceV0.DatasourceAccessInfo{},
// Query handler
&queryV0.QueryDataRequest{},
@ -263,6 +264,10 @@ func (b *DataSourceAPIBuilder) UpdateAPIGroupInfo(apiGroupInfo *genericapiserver
return err
}
storage[ds.StoragePath()], err = opts.DualWriteBuilder(ds.GroupResource(), legacyStore, unified)
storage[ds.StoragePath("access")] = &subAccessREST{
builder: b,
getter: legacyStore,
}
if err != nil {
return err
}

View file

@ -0,0 +1,57 @@
package datasource
import (
"context"
"net/http"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apiserver/pkg/registry/rest"
datasourceV0alpha1 "github.com/grafana/grafana/pkg/apis/datasource/v0alpha1"
"github.com/grafana/grafana/pkg/services/accesscontrol"
"github.com/grafana/grafana/pkg/services/contexthandler"
"github.com/grafana/grafana/pkg/services/datasources"
)
type subAccessREST struct {
builder *DataSourceAPIBuilder
getter rest.Getter
}
var _ = rest.Connecter(&subAccessREST{})
func (r *subAccessREST) New() runtime.Object {
return &datasourceV0alpha1.DatasourceAccessInfo{}
}
func (r *subAccessREST) Destroy() {
// no-op implemenation needed for rest.Storage interface.
}
func (r *subAccessREST) ConnectMethods() []string {
return []string{"GET"}
}
func (r *subAccessREST) NewConnectOptions() (runtime.Object, bool, string) {
return nil, false, "" // true means you can use the trailing path as a variable
}
func (r *subAccessREST) 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) {
access, err := r.getAccessInfo(ctx, name)
if err != nil {
responder.Error(err)
} else {
responder.Object(200, access)
}
}), nil
}
func (r *subAccessREST) getAccessInfo(ctx context.Context, name string) (*datasourceV0alpha1.DatasourceAccessInfo, error) {
reqContext := contexthandler.FromContext(ctx)
resourceIDs := map[string]bool{datasources.ScopePrefix: true}
access := accesscontrol.GetResourcesMetadata(reqContext.Req.Context(), reqContext.GetPermissions(), datasources.ScopePrefix, resourceIDs)
return &datasourceV0alpha1.DatasourceAccessInfo{
Permissions: access[datasources.ScopePrefix],
}, nil
}

View file

@ -1,11 +1,11 @@
package dashboards
package datasource
import (
"context"
"encoding/json"
"errors"
"fmt"
"maps"
"net/http"
"slices"
"testing"
@ -16,7 +16,9 @@ import (
"k8s.io/apimachinery/pkg/runtime/schema"
"github.com/grafana/grafana/pkg/apimachinery/utils"
datasourceV0alpha1 "github.com/grafana/grafana/pkg/apis/datasource/v0alpha1"
grafanarest "github.com/grafana/grafana/pkg/apiserver/rest"
"github.com/grafana/grafana/pkg/services/accesscontrol"
"github.com/grafana/grafana/pkg/services/datasources"
"github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/setting"
@ -34,122 +36,115 @@ func TestIntegrationTestDatasource(t *testing.T) {
testutil.SkipIntegrationTestInShortMode(t)
expectedAPIVersion := "grafana-testdata-datasource.datasource.grafana.app/v0alpha1"
for _, mode := range []grafanarest.DualWriterMode{
grafanarest.Mode0, // Legacy only
grafanarest.Mode2, // write both, read legacy
grafanarest.Mode3, // write both, read unified
grafanarest.Mode5, // Unified only
} {
t.Run(fmt.Sprintf("testdata (mode:%d)", mode), func(t *testing.T) {
ctx := context.Background()
helper := apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{
DisableAnonymous: true,
EnableFeatureToggles: []string{
featuremgmt.FlagGrafanaAPIServerWithExperimentalAPIs, // Required to start the datasource api servers
featuremgmt.FlagQueryServiceWithConnections, // enables CRUD endpoints
ctx := context.Background()
helper := apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{
DisableAnonymous: true,
EnableFeatureToggles: []string{
featuremgmt.FlagGrafanaAPIServerWithExperimentalAPIs, // Required to start the datasource api servers
featuremgmt.FlagQueryServiceWithConnections, // enables CRUD endpoints
},
UnifiedStorageConfig: map[string]setting.UnifiedStorageConfig{
"datasources.grafana-testdata-datasource.datasource.grafana.app": {
DualWriterMode: grafanarest.Mode0,
},
},
})
client := helper.Org1.Admin.ResourceClient(t, schema.GroupVersionResource{
Group: "grafana-testdata-datasource.datasource.grafana.app",
Version: "v0alpha1",
Resource: "datasources",
}).Namespace("default")
t.Run("create", func(t *testing.T) {
out, err := client.Create(ctx, &unstructured.Unstructured{
Object: map[string]any{
"apiVersion": "grafana-testdata-datasource.datasource.grafana.app/v0alpha1",
"kind": "DataSource",
"metadata": map[string]any{
"name": "test",
},
UnifiedStorageConfig: map[string]setting.UnifiedStorageConfig{
"datasources.grafana-testdata-datasource.datasource.grafana.app": {
DualWriterMode: mode,
"spec": map[string]any{
"title": "test",
},
"secure": map[string]any{
"aaa": map[string]any{
"create": "AAA",
},
"bbb": map[string]any{
"create": "BBB",
},
},
})
},
}, metav1.CreateOptions{})
require.NoError(t, err)
require.Equal(t, "test", out.GetName())
require.Equal(t, expectedAPIVersion, out.GetAPIVersion())
client := helper.Org1.Admin.ResourceClient(t, schema.GroupVersionResource{
Group: "grafana-testdata-datasource.datasource.grafana.app",
Version: "v0alpha1",
Resource: "datasources",
}).Namespace("default")
obj, err := utils.MetaAccessor(out)
require.NoError(t, err)
t.Run("create", func(t *testing.T) {
out, err := client.Create(ctx, &unstructured.Unstructured{
Object: map[string]any{
"apiVersion": "grafana-testdata-datasource.datasource.grafana.app/v0alpha1",
"kind": "DataSource",
"metadata": map[string]any{
"name": "test",
},
"spec": map[string]any{
"title": "test",
},
"secure": map[string]any{
"aaa": map[string]any{
"create": "AAA",
},
"bbb": map[string]any{
"create": "BBB",
},
},
secure, err := obj.GetSecureValues()
require.NoError(t, err)
keys := slices.Collect(maps.Keys(secure))
require.ElementsMatch(t, []string{"aaa", "bbb"}, keys)
})
t.Run("update", func(t *testing.T) {
out, err := client.Update(ctx, &unstructured.Unstructured{
Object: map[string]any{
"apiVersion": "grafana-testdata-datasource.datasource.grafana.app/v0alpha1",
"metadata": map[string]any{
"name": "test",
},
"spec": map[string]any{
"title": "test",
"database": "testdb",
"url": "http://fake.url",
"access": datasources.DS_ACCESS_PROXY,
"user": "example",
"isDefault": true,
"readOnly": true,
"jsonData": map[string]any{
"hello": "world",
},
}, metav1.CreateOptions{})
require.NoError(t, err)
require.Equal(t, "test", out.GetName())
require.Equal(t, expectedAPIVersion, out.GetAPIVersion())
obj, err := utils.MetaAccessor(out)
require.NoError(t, err)
secure, err := obj.GetSecureValues()
require.NoError(t, err)
keys := slices.Collect(maps.Keys(secure))
require.ElementsMatch(t, []string{"aaa", "bbb"}, keys)
})
t.Run("update", func(t *testing.T) {
out, err := client.Update(ctx, &unstructured.Unstructured{
Object: map[string]any{
"apiVersion": "grafana-testdata-datasource.datasource.grafana.app/v0alpha1",
"metadata": map[string]any{
"name": "test",
},
"spec": map[string]any{
"title": "test",
"database": "testdb",
"url": "http://fake.url",
"access": datasources.DS_ACCESS_PROXY,
"user": "example",
"isDefault": true,
"readOnly": true,
"jsonData": map[string]any{
"hello": "world",
},
},
"secure": map[string]any{
// "aaa": map[string]any{
// "remove": true, // remove does not really remove in legacy!
// },
"ccc": map[string]any{
"create": "CCC", // add a third value
},
},
},
"secure": map[string]any{
// "aaa": map[string]any{
// "remove": true, // remove does not really remove in legacy!
// },
"ccc": map[string]any{
"create": "CCC", // add a third value
},
}, metav1.UpdateOptions{})
require.NoError(t, err)
require.Equal(t, "test", out.GetName())
require.Equal(t, expectedAPIVersion, out.GetAPIVersion())
},
},
}, metav1.UpdateOptions{})
require.NoError(t, err)
require.Equal(t, "test", out.GetName())
require.Equal(t, expectedAPIVersion, out.GetAPIVersion())
obj, err := utils.MetaAccessor(out)
require.NoError(t, err)
obj, err := utils.MetaAccessor(out)
require.NoError(t, err)
secure, err := obj.GetSecureValues()
require.NoError(t, err)
secure, err := obj.GetSecureValues()
require.NoError(t, err)
keys := slices.Collect(maps.Keys(secure))
require.ElementsMatch(t, []string{"aaa", "bbb", "ccc"}, keys)
})
keys := slices.Collect(maps.Keys(secure))
require.ElementsMatch(t, []string{"aaa", "bbb", "ccc"}, keys)
})
t.Run("list", func(t *testing.T) {
list, err := client.List(ctx, metav1.ListOptions{})
require.NoError(t, err)
require.Equal(t, expectedAPIVersion, list.GetAPIVersion())
require.Len(t, list.Items, 1, "expected a single datasource")
require.Equal(t, "test", list.Items[0].GetName(), "with the test uid")
t.Run("list", func(t *testing.T) {
list, err := client.List(ctx, metav1.ListOptions{})
require.NoError(t, err)
require.Equal(t, expectedAPIVersion, list.GetAPIVersion())
require.Len(t, list.Items, 1, "expected a single datasource")
require.Equal(t, "test", list.Items[0].GetName(), "with the test uid")
spec, _, _ := unstructured.NestedMap(list.Items[0].Object, "spec")
jj, _ := json.MarshalIndent(spec, "", " ")
// fmt.Printf("%s\n", string(jj))
require.JSONEq(t, `{
spec, _, _ := unstructured.NestedMap(list.Items[0].Object, "spec")
jj, _ := json.MarshalIndent(spec, "", " ")
// fmt.Printf("%s\n", string(jj))
require.JSONEq(t, `{
"access": "proxy",
"database": "testdb",
"isDefault": true,
@ -161,63 +156,109 @@ func TestIntegrationTestDatasource(t *testing.T) {
"url": "http://fake.url",
"user": "example"
}`, string(jj))
})
})
t.Run("execute", func(t *testing.T) {
client := helper.Org1.Admin.ResourceClient(t, schema.GroupVersionResource{
Group: "grafana-testdata-datasource.datasource.grafana.app",
Version: "v0alpha1",
Resource: "datasources",
}).Namespace("default")
ctx := context.Background()
t.Run("execute", func(t *testing.T) {
client := helper.Org1.Admin.ResourceClient(t, schema.GroupVersionResource{
Group: "grafana-testdata-datasource.datasource.grafana.app",
Version: "v0alpha1",
Resource: "datasources",
}).Namespace("default")
ctx := context.Background()
list, err := client.List(ctx, metav1.ListOptions{})
require.NoError(t, err)
require.Len(t, list.Items, 1, "expected a single connection")
require.Equal(t, "test", list.Items[0].GetName(), "with the test uid")
list, err := client.List(ctx, metav1.ListOptions{})
require.NoError(t, err)
require.Len(t, list.Items, 1, "expected a single connection")
require.Equal(t, "test", list.Items[0].GetName(), "with the test uid")
_, err = client.Get(ctx, "test", metav1.GetOptions{}, "health")
// endpoint is disabled currently because it has not been
// sufficiently tested.
// for more info see pkg/registry/apis/datasource/sub_health.go
require.Error(t, err)
var statusErr *apierrors.StatusError
require.True(t, errors.As(err, &statusErr))
require.Equal(t, int32(501), statusErr.ErrStatus.Code)
// require.NoError(t, err)
// body, err := rsp.MarshalJSON()
// require.NoError(t, err)
// //fmt.Printf("GOT: %v\n", string(body))
// require.JSONEq(t, `{
// "apiVersion": "grafana-testdata-datasource.datasource.grafana.app/v0alpha1",
// "code": 1,
// "kind": "HealthCheckResult",
// "message": "Data source is working",
// "status": "OK"
// }
// `, string(body))
_, err = client.Get(ctx, "test", metav1.GetOptions{}, "health")
// endpoint is disabled currently because it has not been
// sufficiently tested.
// for more info see pkg/registry/apis/datasource/sub_health.go
require.Error(t, err)
var statusErr *apierrors.StatusError
require.True(t, errors.As(err, &statusErr))
require.Equal(t, int32(501), statusErr.ErrStatus.Code)
// require.NoError(t, err)
// body, err := rsp.MarshalJSON()
// require.NoError(t, err)
// //fmt.Printf("GOT: %v\n", string(body))
// require.JSONEq(t, `{
// "apiVersion": "grafana-testdata-datasource.datasource.grafana.app/v0alpha1",
// "code": 1,
// "kind": "HealthCheckResult",
// "message": "Data source is working",
// "status": "OK"
// }
// `, string(body))
// Test connecting to non-JSON marshaled data
raw := apis.DoRequest[any](helper, apis.RequestParams{
User: helper.Org1.Admin,
Method: "GET",
Path: "/apis/grafana-testdata-datasource.datasource.grafana.app/v0alpha1/namespaces/default/datasources/test/resource",
}, nil)
// endpoint is disabled currently because it has not been
// sufficiently tested.
// for more info see pkg/registry/apis/datasource/sub_resource.go
require.Equal(t, int32(501), raw.Status.Code)
// require.Equal(t, `Hello world from test datasource!`, string(raw.Body))
})
// Test connecting to non-JSON marshaled data
raw := apis.DoRequest[any](helper, apis.RequestParams{
User: helper.Org1.Admin,
Method: "GET",
Path: "/apis/grafana-testdata-datasource.datasource.grafana.app/v0alpha1/namespaces/default/datasources/test/resource",
}, nil)
// endpoint is disabled currently because it has not been
// sufficiently tested.
// for more info see pkg/registry/apis/datasource/sub_resource.go
require.Equal(t, int32(501), raw.Status.Code)
// require.Equal(t, `Hello world from test datasource!`, string(raw.Body))
})
t.Run("delete", func(t *testing.T) {
err := client.Delete(ctx, "test", metav1.DeleteOptions{})
require.NoError(t, err)
t.Run("delete", func(t *testing.T) {
err := client.Delete(ctx, "test", metav1.DeleteOptions{})
require.NoError(t, err)
list, err := client.List(ctx, metav1.ListOptions{})
require.NoError(t, err)
require.Empty(t, list.Items)
})
})
}
list, err := client.List(ctx, metav1.ListOptions{})
require.NoError(t, err)
require.Empty(t, list.Items)
})
}
func TestIntegrationTestDatasourceAccess(t *testing.T) {
testutil.SkipIntegrationTestInShortMode(t)
t.Run("get DatasourceAccessInfo", func(t *testing.T) {
helper := apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{
DisableAnonymous: true,
EnableFeatureToggles: []string{
featuremgmt.FlagGrafanaAPIServerWithExperimentalAPIs, // Required to start the datasource api servers
featuremgmt.FlagQueryServiceWithConnections, // enables CRUD endpoints
},
UnifiedStorageConfig: map[string]setting.UnifiedStorageConfig{
"datasources.grafana-testdata-datasource.datasource.grafana.app": {
DualWriterMode: grafanarest.Mode0,
},
},
})
var datasourceAccessInfo datasourceV0alpha1.DatasourceAccessInfo
raw := apis.DoRequest(helper, apis.RequestParams{
User: helper.Org1.Admin,
Method: "GET",
Path: "/apis/grafana-testdata-datasource.datasource.grafana.app/v0alpha1/namespaces/default/datasources/test/access",
}, &datasourceAccessInfo)
require.Equal(t, http.StatusOK, raw.Response.StatusCode)
expectedDatasourceAccessInfo := datasourceV0alpha1.DatasourceAccessInfo{
TypeMeta: metav1.TypeMeta{
Kind: "DatasourceAccessInfo",
APIVersion: "grafana-testdata-datasource.datasource.grafana.app/v0alpha1",
},
Permissions: accesscontrol.Metadata{
"alert.instances.external:read": true,
"alert.instances.external:write": true,
"alert.notifications.external:read": true,
"alert.notifications.external:write": true,
"alert.rules.external:read": true,
"alert.rules.external:write": true,
"datasources.id:read": true,
"datasources:delete": true,
"datasources:query": true,
"datasources:read": true,
"datasources:write": true,
},
}
require.Equal(t, datasourceAccessInfo, expectedDatasourceAccessInfo)
})
}

View file

@ -849,6 +849,55 @@
}
]
},
"/apis/grafana-testdata-datasource.datasource.grafana.app/v0alpha1/namespaces/{namespace}/datasources/{name}/access": {
"get": {
"tags": [
"DataSource"
],
"description": "connect GET requests to access of DataSource",
"operationId": "getDataSourceAccess",
"responses": {
"200": {
"description": "OK",
"content": {
"*/*": {
"schema": {
"type": "string"
}
}
}
}
},
"x-kubernetes-action": "connect",
"x-kubernetes-group-version-kind": {
"group": "grafana-testdata-datasource.datasource.grafana.app",
"kind": "DatasourceAccessInfo",
"version": "v0alpha1"
}
},
"parameters": [
{
"name": "name",
"in": "path",
"description": "name of the DatasourceAccessInfo",
"required": true,
"schema": {
"type": "string",
"uniqueItems": true
}
},
{
"name": "namespace",
"in": "path",
"description": "object name and auth scope, such as for teams and projects",
"required": true,
"schema": {
"type": "string",
"uniqueItems": true
}
}
]
},
"/apis/grafana-testdata-datasource.datasource.grafana.app/v0alpha1/namespaces/{namespace}/datasources/{name}/health": {
"get": {
"tags": [
@ -1876,4 +1925,4 @@
}
}
}
}
}

View file

@ -1,6 +1,7 @@
import { lastValueFrom } from 'rxjs';
import { DataSourceSettings } from '@grafana/data';
import { DataSourceSettings, DataSourceJsonData } from '@grafana/data';
import { config } from '@grafana/runtime';
import { getBackendSrv } from 'app/core/services/backend_srv';
import { accessControlQueryParam } from 'app/core/utils/accessControl';
@ -8,7 +9,115 @@ export const getDataSources = async (): Promise<DataSourceSettings[]> => {
return await getBackendSrv().get('/api/datasources');
};
export interface K8sMetadata {
name: string;
namespace: string;
uid: string;
resourceVersion: string;
generation: number;
creationTimestamp: string;
labels: Map<string, string>;
annotations: Map<string, string>;
}
export interface DatasourceInstanceK8sSpec {
access: string;
jsonData: DataSourceJsonData;
title: string;
url: string;
basicAuth: boolean;
basicAuthUser: string;
}
export interface DatasourceAccessK8s {
kind: string;
apiVersion: string;
Permissions: Record<string, boolean>;
}
export interface DataSourceSettingsK8s {
kind: string;
apiVersion: string;
metadata: K8sMetadata;
spec: DatasourceInstanceK8sSpec;
}
export const getDataSourceK8sGroup = (uid: string): string => {
for (const [key, ds] of Object.entries(config.datasources)) {
if (key.startsWith('--')) {
continue;
}
if (config.datasources[key].uid === uid) {
return ds.type + '.datasource.grafana.app';
}
}
return '';
};
export const getDataSourceFromK8sAPI = async (k8sName: string, stackId: string) => {
// TODO: read this from backend.
let k8sVersion = 'v0alpha1';
let k8sGroup = getDataSourceK8sGroup(k8sName);
if (k8sGroup === '') {
throw Error(`Could not find data source group with uid: "${k8sName}"`);
}
const response = await lastValueFrom(
getBackendSrv().fetch<DataSourceSettingsK8s>({
method: 'GET',
url: `/apis/${k8sGroup}/${k8sVersion}/namespaces/${stackId}/datasources/${k8sName}`,
showErrorAlert: false,
})
);
if (!response.ok) {
throw Error(`Could not find data source by group-version-name: "${k8sGroup}" "${k8sVersion}" "${k8sName}"`);
}
let dsK8sSettings = response.data;
let labels = new Map(Object.entries(dsK8sSettings.metadata.labels));
let id = parseInt(labels.get('grafana.app/deprecatedInternalID') || '', 10);
let dsSettings: DataSourceSettings = {
id: id,
uid: dsK8sSettings.metadata.name,
orgId: 1,
name: dsK8sSettings.spec.title,
typeLogoUrl: '',
type: dsK8sSettings.apiVersion.replace(/\.datasource\.grafana\.app\/[a-z0-9]+$/, ''),
typeName: '',
access: dsK8sSettings.spec.access,
url: dsK8sSettings.spec.url,
user: '',
database: '',
basicAuth: dsK8sSettings.spec.basicAuth,
basicAuthUser: dsK8sSettings.spec.basicAuthUser,
isDefault: false,
jsonData: dsK8sSettings.spec.jsonData,
secureJsonFields: {},
readOnly: false,
withCredentials: false,
};
const accessResponse = await lastValueFrom(
getBackendSrv().fetch<DatasourceAccessK8s>({
method: 'GET',
url: `/apis/${k8sGroup}/${k8sVersion}/namespaces/${stackId}/datasources/${k8sName}/access`,
showErrorAlert: false,
})
);
if (!accessResponse.ok) {
throw Error(
`Could not find data source access information by group-version-name: "${k8sGroup}" "${k8sVersion}" "${k8sName}"`
);
}
dsSettings.accessControl = accessResponse.data.Permissions;
return dsSettings;
};
export const getDataSourceByUid = async (uid: string) => {
if (config.featureToggles.queryServiceWithConnections) {
return getDataSourceFromK8sAPI(uid, config.namespace);
}
const response = await lastValueFrom(
getBackendSrv().fetch<DataSourceSettings>({
method: 'GET',