From eda94a6434efc84862f9907c40463c02460c400d Mon Sep 17 00:00:00 2001 From: Ryan McKinley Date: Thu, 28 Aug 2025 22:28:26 +0300 Subject: [PATCH] DataSource: Support config CRUD from apiservers (#106996) --- go.mod | 5 - go.sum | 13 -- go.work.sum | 1 + .../src/types/featureToggles.gen.ts | 4 + pkg/aggregator/apiserver/plugin/handler.go | 4 +- pkg/aggregator/apiserver/plugin/query.go | 6 +- pkg/aggregator/apiserver/plugin/query_test.go | 13 +- pkg/apis/datasource/v0alpha1/datasource.go | 73 +++++++ pkg/apis/datasource/v0alpha1/doc.go | 3 - pkg/apis/datasource/v0alpha1/register.go | 23 +-- pkg/apis/datasource/v0alpha1/types.go | 22 +- pkg/apis/datasource/v0alpha1/unstructured.go | 173 ++++++++++++++++ .../v0alpha1/zz_generated.deepcopy.go | 31 ++- .../v0alpha1/zz_generated.defaults.go | 19 -- .../v0alpha1/zz_generated.openapi.go | 132 ++++++++++-- ...enerated.openapi_violation_exceptions.list | 2 + .../v0alpha1/{datasource.go => connection.go} | 38 +++- pkg/apis/query/v0alpha1/register.go | 30 +++ .../query/v0alpha1/zz_generated.deepcopy.go | 76 +++++++ .../query/v0alpha1/zz_generated.openapi.go | 149 +++++++++++++- ...enerated.openapi_violation_exceptions.list | 2 + pkg/extensions/enterprise_imports.go | 1 - pkg/registry/apis/datasource/authorizer.go | 2 +- pkg/registry/apis/datasource/connections.go | 59 ------ pkg/registry/apis/datasource/converter.go | 192 ++++++++++++++++++ .../apis/datasource/converter_test.go | 153 ++++++++++++++ pkg/registry/apis/datasource/legacy_store.go | 126 ++++++++++++ pkg/registry/apis/datasource/noop.go | 42 ++++ pkg/registry/apis/datasource/openapi.go | 74 +++++++ pkg/registry/apis/datasource/plugincontext.go | 131 ++++++++---- pkg/registry/apis/datasource/querier.go | 51 ----- pkg/registry/apis/datasource/register.go | 129 ++++++------ pkg/registry/apis/datasource/sub_query.go | 22 +- .../apis/datasource/sub_query_test.go | 43 +++- pkg/registry/apis/datasource/sub_resource.go | 2 +- .../apis/datasource/sub_resource_test.go | 12 +- .../convert-dto-empty-to-resource.json | 16 ++ .../testdata/convert-dto-empty.json | 8 + .../testdata/convert-dto-invalid.json | 8 + .../convert-dto-testdata-to-resource.json | 39 ++++ .../testdata/convert-dto-testdata.json | 27 +++ .../convert-resource-empty-to-cmd-add.json | 15 ++ ...esource-empty-to-cmd-update-roundtrip.json | 12 ++ .../convert-resource-empty-to-cmd-update.json | 16 ++ .../testdata/convert-resource-empty.json | 8 + .../convert-resource-full-to-cmd-add.json | 22 ++ ...resource-full-to-cmd-update-roundtrip.json | 32 +++ .../convert-resource-full-to-cmd-update.json | 23 +++ .../testdata/convert-resource-full.json | 33 +++ .../testdata/convert-resource-invalid.json | 17 ++ .../testdata/convert-resource-invalid2.json | 9 + pkg/registry/apis/query/connections.go | 161 +++++++++++++++ .../apis/query/queryschema/oas_helper.go | 15 +- pkg/registry/apis/query/register.go | 16 ++ pkg/server/wire_gen.go | 4 +- pkg/services/featuremgmt/registry.go | 7 + pkg/services/featuremgmt/toggles_gen.csv | 1 + pkg/services/featuremgmt/toggles_gen.go | 4 + pkg/services/featuremgmt/toggles_gen.json | 13 ++ .../datasource/testdata/testdata-create.yaml | 17 ++ pkg/tests/apis/datasource/testdata_test.go | 128 +++++------- 61 files changed, 2041 insertions(+), 468 deletions(-) create mode 100644 pkg/apis/datasource/v0alpha1/datasource.go create mode 100644 pkg/apis/datasource/v0alpha1/unstructured.go delete mode 100644 pkg/apis/datasource/v0alpha1/zz_generated.defaults.go create mode 100644 pkg/apis/datasource/v0alpha1/zz_generated.openapi_violation_exceptions.list rename pkg/apis/query/v0alpha1/{datasource.go => connection.go} (54%) delete mode 100644 pkg/registry/apis/datasource/connections.go create mode 100644 pkg/registry/apis/datasource/converter.go create mode 100644 pkg/registry/apis/datasource/converter_test.go create mode 100644 pkg/registry/apis/datasource/legacy_store.go create mode 100644 pkg/registry/apis/datasource/noop.go create mode 100644 pkg/registry/apis/datasource/openapi.go create mode 100644 pkg/registry/apis/datasource/testdata/convert-dto-empty-to-resource.json create mode 100644 pkg/registry/apis/datasource/testdata/convert-dto-empty.json create mode 100644 pkg/registry/apis/datasource/testdata/convert-dto-invalid.json create mode 100644 pkg/registry/apis/datasource/testdata/convert-dto-testdata-to-resource.json create mode 100644 pkg/registry/apis/datasource/testdata/convert-dto-testdata.json create mode 100644 pkg/registry/apis/datasource/testdata/convert-resource-empty-to-cmd-add.json create mode 100644 pkg/registry/apis/datasource/testdata/convert-resource-empty-to-cmd-update-roundtrip.json create mode 100644 pkg/registry/apis/datasource/testdata/convert-resource-empty-to-cmd-update.json create mode 100644 pkg/registry/apis/datasource/testdata/convert-resource-empty.json create mode 100644 pkg/registry/apis/datasource/testdata/convert-resource-full-to-cmd-add.json create mode 100644 pkg/registry/apis/datasource/testdata/convert-resource-full-to-cmd-update-roundtrip.json create mode 100644 pkg/registry/apis/datasource/testdata/convert-resource-full-to-cmd-update.json create mode 100644 pkg/registry/apis/datasource/testdata/convert-resource-full.json create mode 100644 pkg/registry/apis/datasource/testdata/convert-resource-invalid.json create mode 100644 pkg/registry/apis/datasource/testdata/convert-resource-invalid2.json create mode 100644 pkg/registry/apis/query/connections.go create mode 100644 pkg/tests/apis/datasource/testdata/testdata-create.yaml diff --git a/go.mod b/go.mod index f2caa2c978b..889affe78de 100644 --- a/go.mod +++ b/go.mod @@ -456,7 +456,6 @@ require ( github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect - github.com/jaegertracing/jaeger v1.67.0 // indirect github.com/jaegertracing/jaeger-idl v0.5.0 // indirect github.com/jcmturner/aescts/v2 v2.0.0 // indirect github.com/jcmturner/dnsutils/v2 v2.0.0 // indirect @@ -518,9 +517,6 @@ require ( github.com/oklog/ulid v1.3.1 // indirect github.com/oklog/ulid/v2 v2.1.0 // indirect github.com/open-feature/go-sdk-contrib/providers/ofrep v0.1.5 // indirect - github.com/open-telemetry/opentelemetry-collector-contrib/internal/coreinternal v0.124.1 // indirect - github.com/open-telemetry/opentelemetry-collector-contrib/pkg/core/xidutils v0.124.1 // indirect - github.com/open-telemetry/opentelemetry-collector-contrib/pkg/translator/jaeger v0.124.1 // indirect github.com/opencontainers/go-digest v1.0.0 // indirect github.com/opencontainers/image-spec v1.1.1 // indirect github.com/opentracing-contrib/go-stdlib v1.0.0 // indirect @@ -586,7 +582,6 @@ require ( go.etcd.io/etcd/client/v3 v3.5.21 // indirect go.mongodb.org/mongo-driver v1.17.3 // indirect go.opentelemetry.io/auto/sdk v1.1.0 // indirect - go.opentelemetry.io/collector/semconv v0.124.0 // indirect go.opentelemetry.io/contrib/bridges/prometheus v0.61.0 // indirect go.opentelemetry.io/contrib/detectors/gcp v1.36.0 // indirect go.opentelemetry.io/contrib/exporters/autoexport v0.61.0 // indirect diff --git a/go.sum b/go.sum index d5cd9b38420..a285f135834 100644 --- a/go.sum +++ b/go.sum @@ -1793,8 +1793,6 @@ github.com/jackc/pgx/v5 v5.7.5 h1:JHGfMnQY+IEtGM63d+NGMjoRpysB2JBwDr5fsngwmJs= github.com/jackc/pgx/v5 v5.7.5/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M= github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= -github.com/jaegertracing/jaeger v1.67.0 h1:t0BiJZVW9D3Z16y3uHqKzV9bKFTusooTH1Kgr77xF2Q= -github.com/jaegertracing/jaeger v1.67.0/go.mod h1:tE/FEQfybCSdUbBgel51YaCSkc58O+Njih8oTl6j8vw= github.com/jaegertracing/jaeger-idl v0.5.0 h1:zFXR5NL3Utu7MhPg8ZorxtCBjHrL3ReM1VoB65FOFGE= github.com/jaegertracing/jaeger-idl v0.5.0/go.mod h1:ON90zFo9eoyXrt9F/KN8YeF3zxcnujaisMweFY/rg5k= github.com/jcmturner/aescts/v2 v2.0.0 h1:9YKLH6ey7H4eDBXW8khjYslgyqG2xZikXP0EQFKrle8= @@ -2107,12 +2105,6 @@ github.com/open-feature/go-sdk-contrib/providers/go-feature-flag v0.2.3 h1:6jpO6 github.com/open-feature/go-sdk-contrib/providers/go-feature-flag v0.2.3/go.mod h1:dPUHjAIFzg+ci/wt6XxlNiiMkOh5Yw4SGyeRY0AFT0g= github.com/open-feature/go-sdk-contrib/providers/ofrep v0.1.5 h1:ZdqlGnNwhWf3luhBQlIpbglvcCzjkcuEgOEhYhr5Emc= github.com/open-feature/go-sdk-contrib/providers/ofrep v0.1.5/go.mod h1:jrD4UG3ZCzuwImKHlyuIN2iWeYjlOX5+zJ/sX45efuE= -github.com/open-telemetry/opentelemetry-collector-contrib/internal/coreinternal v0.124.1 h1:NrjsoVPxI6lmV8jPImDcMeqYh+97Y71f/HB5Sfpfe3I= -github.com/open-telemetry/opentelemetry-collector-contrib/internal/coreinternal v0.124.1/go.mod h1:AFMryJmht7dZxcAwc2sx/r4gxbriElWw49ugxKp2mcA= -github.com/open-telemetry/opentelemetry-collector-contrib/pkg/core/xidutils v0.124.1 h1:E1e96GTHmiAfIfeYfA5ZVnOxud3+vbisGp0gE1tfd4s= -github.com/open-telemetry/opentelemetry-collector-contrib/pkg/core/xidutils v0.124.1/go.mod h1:MOhFATtYSLad9nKunjh6uGf8nQUcWje2LPlhD2uu3do= -github.com/open-telemetry/opentelemetry-collector-contrib/pkg/translator/jaeger v0.124.1 h1:9c6L4xlAMqhOg5y54Bc2B5t0i49yz7v2I6I8RY4Z0/o= -github.com/open-telemetry/opentelemetry-collector-contrib/pkg/translator/jaeger v0.124.1/go.mod h1:6f0N58o0cOHC0ApSM/qrooVmQza1eQ7L53PDE91uO1Q= github.com/opencontainers/go-digest v1.0.0-rc1/go.mod h1:cMLVZDEM3+U2I4VmLI6N8jQYUd2OVphdqWwCJHrFt2s= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= @@ -2554,13 +2546,8 @@ go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0= go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo= go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= -go.opentelemetry.io/collector v0.124.0 h1:g/dfdGFhBcQI0ggGxTmGlJnJ6Yl6T2gVxQoIj4UfXCc= go.opentelemetry.io/collector/pdata v1.30.0 h1:j3jyq9um436r6WzWySzexP2nLnFdmL5uVBYAlyr9nDM= go.opentelemetry.io/collector/pdata v1.30.0/go.mod h1:0Bxu1ktuj4wE7PIASNSvd0SdBscQ1PLtYasymJ13/Cs= -go.opentelemetry.io/collector/pdata/pprofile v0.124.0 h1:ZjL9wKqzP4BHj0/F1jfGxs1Va8B7xmYayipZeNVoWJE= -go.opentelemetry.io/collector/pdata/pprofile v0.124.0/go.mod h1:1EN3Gw5LSI4fSVma/Yfv/6nqeuYgRTm1/kmG5nE5Oyo= -go.opentelemetry.io/collector/semconv v0.124.0 h1:YTdo3UFwNyDQCh9DiSm2rbzAgBuwn/9dNZ0rv454goA= -go.opentelemetry.io/collector/semconv v0.124.0/go.mod h1:te6VQ4zZJO5Lp8dM2XIhDxDiL45mwX0YAQQWRQ0Qr9U= go.opentelemetry.io/contrib/bridges/prometheus v0.61.0 h1:RyrtJzu5MAmIcbRrwg75b+w3RlZCP0vJByDVzcpAe3M= go.opentelemetry.io/contrib/bridges/prometheus v0.61.0/go.mod h1:tirr4p9NXbzjlbruiRGp53IzlYrDk5CO2fdHj0sSSaY= go.opentelemetry.io/contrib/detectors/gcp v1.36.0 h1:F7q2tNlCaHY9nMKHR6XH9/qkp8FktLnIcy6jJNyOCQw= diff --git a/go.work.sum b/go.work.sum index d7e4f2a81f2..5422650d2c7 100644 --- a/go.work.sum +++ b/go.work.sum @@ -1485,6 +1485,7 @@ github.com/twmb/murmur3 v1.1.8 h1:8Yt9taO/WN3l08xErzjeschgZU2QSrwm1kclYq+0aRg= github.com/twmb/murmur3 v1.1.8/go.mod h1:Qq/R7NUyOfr65zD+6Q5IHKsJLwP7exErjN6lyyq3OSQ= github.com/uber-go/atomic v1.4.0 h1:yOuPqEq4ovnhEjpHmfFwsqBXDYbQeT6Nb0bwD6XnD5o= github.com/uber-go/atomic v1.4.0/go.mod h1:/Ct5t2lcmbJ4OSe/waGBoaVvVqtO0bmtfVNex1PFV8g= +github.com/urfave/cli/v2 v2.27.6/go.mod h1:3Sevf16NykTbInEnD0yKkjDAeZDS0A6bzhBH5hrMvTQ= github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo= github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ= github.com/vertica/vertica-sql-go v1.3.3 h1:fL+FKEAEy5ONmsvya2WH5T8bhkvY27y/Ik3ReR2T+Qw= diff --git a/packages/grafana-data/src/types/featureToggles.gen.ts b/packages/grafana-data/src/types/featureToggles.gen.ts index 2523471d4a4..57472bf05f4 100644 --- a/packages/grafana-data/src/types/featureToggles.gen.ts +++ b/packages/grafana-data/src/types/featureToggles.gen.ts @@ -301,6 +301,10 @@ export interface FeatureToggles { */ queryService?: boolean; /** + * Adds datasource connections to the query service + */ + queryServiceWithConnections?: boolean; + /** * Rewrite requests targeting /ds/query to the query service */ queryServiceRewrite?: boolean; diff --git a/pkg/aggregator/apiserver/plugin/handler.go b/pkg/aggregator/apiserver/plugin/handler.go index cd09bf45efb..0cce5708623 100644 --- a/pkg/aggregator/apiserver/plugin/handler.go +++ b/pkg/aggregator/apiserver/plugin/handler.go @@ -5,9 +5,9 @@ import ( "net/http" "path" - "github.com/grafana/grafana-plugin-sdk-go/backend" "k8s.io/apimachinery/pkg/runtime/serializer" + "github.com/grafana/grafana-plugin-sdk-go/backend" aggregationv0alpha1 "github.com/grafana/grafana/pkg/aggregator/apis/aggregation/v0alpha1" "github.com/grafana/grafana/pkg/aggregator/apiserver/plugin/admission" ) @@ -64,7 +64,7 @@ func (h *PluginHandler) registerRoutes() { case aggregationv0alpha1.DataSourceProxyServiceType: // TODO: implement in future PR case aggregationv0alpha1.QueryServiceType: - h.mux.Handle(proxyPath("/namespaces/{namespace}/connections/{uid}/query"), h.QueryDataHandler()) + h.mux.Handle(proxyPath("/namespaces/{namespace}/datasources/{uid}/query"), h.QueryDataHandler()) case aggregationv0alpha1.RouteServiceType: // TODO: implement in future PR case aggregationv0alpha1.StreamServiceType: diff --git a/pkg/aggregator/apiserver/plugin/query.go b/pkg/aggregator/apiserver/plugin/query.go index b790514ef31..c7750b6dc93 100644 --- a/pkg/aggregator/apiserver/plugin/query.go +++ b/pkg/aggregator/apiserver/plugin/query.go @@ -6,15 +6,15 @@ import ( "fmt" "net/http" - "github.com/grafana/grafana-plugin-sdk-go/backend" - data "github.com/grafana/grafana-plugin-sdk-go/experimental/apis/data/v0alpha1" - grafanasemconv "github.com/grafana/grafana/pkg/semconv" semconv "go.opentelemetry.io/otel/semconv/v1.21.0" "k8s.io/component-base/tracing" "k8s.io/klog/v2" + "github.com/grafana/grafana-plugin-sdk-go/backend" + data "github.com/grafana/grafana-plugin-sdk-go/experimental/apis/data/v0alpha1" aggregationv0alpha1 "github.com/grafana/grafana/pkg/aggregator/apis/aggregation/v0alpha1" "github.com/grafana/grafana/pkg/aggregator/apiserver/util" + grafanasemconv "github.com/grafana/grafana/pkg/semconv" ) func (h *PluginHandler) QueryDataHandler() http.HandlerFunc { diff --git a/pkg/aggregator/apiserver/plugin/query_test.go b/pkg/aggregator/apiserver/plugin/query_test.go index 97bfabb3a08..0a9a040a0b6 100644 --- a/pkg/aggregator/apiserver/plugin/query_test.go +++ b/pkg/aggregator/apiserver/plugin/query_test.go @@ -10,13 +10,14 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/grafana/grafana-plugin-sdk-go/backend" "github.com/grafana/grafana-plugin-sdk-go/data" datav0alpha1 "github.com/grafana/grafana-plugin-sdk-go/experimental/apis/data/v0alpha1" "github.com/grafana/grafana/pkg/aggregator/apis/aggregation/v0alpha1" "github.com/grafana/grafana/pkg/aggregator/apiserver/plugin/fakes" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" ) func TestQueryDataHandler(t *testing.T) { @@ -87,7 +88,7 @@ func TestQueryDataHandler(t *testing.T) { buf := bytes.NewBuffer(nil) assert.NoError(t, json.NewEncoder(buf).Encode(qdr)) - req, err := http.NewRequest("POST", "/apis/testds.example.com/v1/namespaces/default/connections/123/query", buf) + req, err := http.NewRequest("POST", "/apis/testds.example.com/v1/namespaces/default/datasources/123/query", buf) assert.NoError(t, err) rr := httptest.NewRecorder() @@ -113,7 +114,7 @@ func TestQueryDataHandler(t *testing.T) { buf := bytes.NewBuffer(nil) assert.NoError(t, json.NewEncoder(buf).Encode(qdr)) - req, err := http.NewRequest("POST", "/apis/testds.example.com/v1/namespaces/default/connections/123/query", buf) + req, err := http.NewRequest("POST", "/apis/testds.example.com/v1/namespaces/default/datasources/123/query", buf) assert.NoError(t, err) rr := httptest.NewRecorder() @@ -141,7 +142,7 @@ func TestQueryDataHandler(t *testing.T) { buf := bytes.NewBuffer(nil) assert.NoError(t, json.NewEncoder(buf).Encode(qdr)) - req, err := http.NewRequest("POST", "/apis/testds.example.com/v1/namespaces/default/connections/abc/query", buf) + req, err := http.NewRequest("POST", "/apis/testds.example.com/v1/namespaces/default/datasources/abc/query", buf) assert.NoError(t, err) rr := httptest.NewRecorder() @@ -165,7 +166,7 @@ func TestQueryDataHandler(t *testing.T) { }) t.Run("should return delegate response if group does not match", func(t *testing.T) { - req, err := http.NewRequest("POST", "/apis/wrongds.example.com/v1/namespaces/default/connections/abc/query", bytes.NewBuffer(nil)) + req, err := http.NewRequest("POST", "/apis/wrongds.example.com/v1/namespaces/default/datasources/abc/query", bytes.NewBuffer(nil)) assert.NoError(t, err) rr := httptest.NewRecorder() diff --git a/pkg/apis/datasource/v0alpha1/datasource.go b/pkg/apis/datasource/v0alpha1/datasource.go new file mode 100644 index 00000000000..54368f4b9ee --- /dev/null +++ b/pkg/apis/datasource/v0alpha1/datasource.go @@ -0,0 +1,73 @@ +package v0alpha1 + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + common "github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1" +) + +// +k8s:deepcopy-gen=true +// +k8s:openapi-gen=true +// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object +type DataSource struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata"` + + // DataSource configuration -- these properties are all visible + // to anyone able to query the data source from their browser + Spec UnstructuredSpec `json:"spec"` + + // Secure values allows setting values that are never shown to users + // The returned properties are only the names of the configured values + Secure common.InlineSecureValues `json:"secure,omitzero,omitempty"` +} + +// DsAccess represents how the datasource connects to the remote service +// +k8s:openapi-gen=true +// +enum +type DsAccess string + +const ( + // The frontend can connect directly to the remote URL + // This method is discouraged + DsAccessDirect DsAccess = "direct" + + // Connect to the remote datasource through the grafana backend + DsAccessProxy DsAccess = "proxy" +) + +func (dsa DsAccess) String() string { + return string(dsa) +} + +// +k8s:openapi-gen=true +type GenericDataSourceSpec struct { + // The display name (previously saved as the "name" property) + Title string `json:"title"` + + Access DsAccess `json:"access,omitempty"` + ReadOnly bool `json:"readOnly,omitempty"` + IsDefault bool `json:"isDefault,omitempty"` + + // Server URL + URL string `json:"url,omitempty"` + + User string `json:"user,omitempty"` + Database string `json:"database,omitempty"` + BasicAuth bool `json:"basicAuth,omitempty"` + BasicAuthUser string `json:"basicAuthUser,omitempty"` + WithCredentials bool `json:"withCredentials,omitempty"` + + // Generic unstructured configuration settings + JsonData common.Unstructured `json:"jsonData,omitzero"` +} + +// +k8s:deepcopy-gen=true +// +k8s:openapi-gen=true +// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object +type DataSourceList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata"` + + Items []DataSource `json:"items"` +} diff --git a/pkg/apis/datasource/v0alpha1/doc.go b/pkg/apis/datasource/v0alpha1/doc.go index 43d5863c3c0..e7a4bf9c23e 100644 --- a/pkg/apis/datasource/v0alpha1/doc.go +++ b/pkg/apis/datasource/v0alpha1/doc.go @@ -1,6 +1,3 @@ -// +k8s:deepcopy-gen=package -// +k8s:openapi-gen=true -// +k8s:defaulter-gen=TypeMeta // +groupName=datasource.grafana.com package v0alpha1 diff --git a/pkg/apis/datasource/v0alpha1/register.go b/pkg/apis/datasource/v0alpha1/register.go index 2333aa0d996..735207452f9 100644 --- a/pkg/apis/datasource/v0alpha1/register.go +++ b/pkg/apis/datasource/v0alpha1/register.go @@ -4,9 +4,10 @@ import ( "fmt" "time" - "github.com/grafana/grafana/pkg/apimachinery/utils" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" + + "github.com/grafana/grafana/pkg/apimachinery/utils" ) const ( @@ -14,26 +15,24 @@ const ( VERSION = "v0alpha1" ) -var GenericConnectionResourceInfo = utils.NewResourceInfo(GROUP, VERSION, - "connections", "connection", "DataSourceConnection", - func() runtime.Object { return &DataSourceConnection{} }, - func() runtime.Object { return &DataSourceConnectionList{} }, +var DataSourceResourceInfo = utils.NewResourceInfo(GROUP, VERSION, + "datasources", "datasource", "DataSource", + func() runtime.Object { return &DataSource{} }, + func() runtime.Object { return &DataSourceList{} }, utils.TableColumns{ Definition: []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: "Title", Type: "string", Format: "string", Description: "Title"}, {Name: "Created At", Type: "date"}, }, - Reader: func(obj any) ([]interface{}, error) { - m, ok := obj.(*DataSourceConnection) + Reader: func(obj any) ([]any, error) { + m, ok := obj.(*DataSource) if !ok { return nil, fmt.Errorf("expected connection") } - return []interface{}{ + return []any{ m.Name, - m.Title, - m.APIVersion, + m.Spec.Object["title"], m.CreationTimestamp.UTC().Format(time.RFC3339), }, nil }, diff --git a/pkg/apis/datasource/v0alpha1/types.go b/pkg/apis/datasource/v0alpha1/types.go index 6fe4d14dd71..f41ce9f0757 100644 --- a/pkg/apis/datasource/v0alpha1/types.go +++ b/pkg/apis/datasource/v0alpha1/types.go @@ -6,26 +6,8 @@ import ( common "github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1" ) -// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object -type DataSourceConnection struct { - metav1.TypeMeta `json:",inline"` - metav1.ObjectMeta `json:"metadata,omitempty"` - - // The display name - Title string `json:"title"` - - // Optional description for the data source (does not exist yet) - Description string `json:"description,omitempty"` -} - -// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object -type DataSourceConnectionList struct { - metav1.TypeMeta `json:",inline"` - metav1.ListMeta `json:"metadata,omitempty"` - - Items []DataSourceConnection `json:"items"` -} - +// +k8s:deepcopy-gen=true +// +k8s:openapi-gen=true // +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object type HealthCheckResult struct { metav1.TypeMeta `json:",inline"` diff --git a/pkg/apis/datasource/v0alpha1/unstructured.go b/pkg/apis/datasource/v0alpha1/unstructured.go new file mode 100644 index 00000000000..67bd2b14530 --- /dev/null +++ b/pkg/apis/datasource/v0alpha1/unstructured.go @@ -0,0 +1,173 @@ +package v0alpha1 + +import ( + "encoding/json" + + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + openapi "k8s.io/kube-openapi/pkg/common" + spec "k8s.io/kube-openapi/pkg/validation/spec" + + common "github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1" +) + +// UnstructuredSpec allows any property to be saved into the spec +// Validation will happen from the dynamically loaded schemas for each datasource +// +k8s:deepcopy-gen=true +// +k8s:openapi-gen=true +type UnstructuredSpec common.Unstructured + +func (u *UnstructuredSpec) GetString(key string) string { + if u.Object == nil { + return "" + } + v := u.Object[key] + str, _ := v.(string) + return str +} + +func (u *UnstructuredSpec) Set(key string, val any) *UnstructuredSpec { + if u.Object == nil { + u.Object = make(map[string]any) + } + if val == nil || val == "" || val == false { + delete(u.Object, key) + } else { + u.Object[key] = val + } + return u +} + +func (u *UnstructuredSpec) Title() string { + return u.GetString("title") +} + +func (u *UnstructuredSpec) SetTitle(v string) *UnstructuredSpec { + return u.Set("title", v) +} + +func (u *UnstructuredSpec) URL() string { + return u.GetString("url") +} + +func (u *UnstructuredSpec) SetURL(v string) *UnstructuredSpec { + return u.Set("url", v) +} + +func (u *UnstructuredSpec) Database() string { + return u.GetString("database") +} + +func (u *UnstructuredSpec) SetDatabase(v string) *UnstructuredSpec { + return u.Set("database", v) +} + +func (u *UnstructuredSpec) Access() DsAccess { + return DsAccess(u.GetString("access")) +} + +func (u *UnstructuredSpec) SetAccess(v string) *UnstructuredSpec { + return u.Set("access", v) +} + +func (u *UnstructuredSpec) User() string { + return u.GetString("user") +} + +func (u *UnstructuredSpec) SetUser(v string) *UnstructuredSpec { + return u.Set("user", v) +} + +func (u *UnstructuredSpec) BasicAuth() bool { + v, _, _ := unstructured.NestedBool(u.Object, "basicAuth") + return v +} + +func (u *UnstructuredSpec) SetBasicAuth(v bool) *UnstructuredSpec { + return u.Set("basicAuth", v) +} + +func (u *UnstructuredSpec) BasicAuthUser() string { + return u.GetString("basicAuthUser") +} + +func (u *UnstructuredSpec) SetBasicAuthUser(v string) *UnstructuredSpec { + return u.Set("basicAuthUser", v) +} + +func (u *UnstructuredSpec) WithCredentials() bool { + v, _, _ := unstructured.NestedBool(u.Object, "withCredentials") + return v +} + +func (u *UnstructuredSpec) SetWithCredentials(v bool) *UnstructuredSpec { + return u.Set("withCredentials", v) +} + +func (u *UnstructuredSpec) IsDefault() bool { + v, _, _ := unstructured.NestedBool(u.Object, "isDefault") + return v +} + +func (u *UnstructuredSpec) SetIsDefault(v bool) *UnstructuredSpec { + return u.Set("isDefault", v) +} + +func (u *UnstructuredSpec) ReadOnly() bool { + v, _, _ := unstructured.NestedBool(u.Object, "readOnly") + return v +} + +func (u *UnstructuredSpec) SetReadOnly(v bool) *UnstructuredSpec { + return u.Set("readOnly", v) +} + +func (u *UnstructuredSpec) JSONData() any { + return u.Object["jsonData"] +} + +func (u *UnstructuredSpec) SetJSONData(v any) *UnstructuredSpec { + return u.Set("jsonData", v) +} + +// The OpenAPI spec uses the generated values from GenericDataSourceSpec, except that it: +// 1. Allows additional properties at the root +// 2. The jsonData field *may* be an raw value OR a map +func (UnstructuredSpec) OpenAPIDefinition() openapi.OpenAPIDefinition { + s := schema_pkg_apis_datasource_v0alpha1_GenericDataSourceSpec(func(path string) spec.Ref { + return spec.MustCreateRef(path) + }) + s.Schema.AdditionalProperties = &spec.SchemaOrBool{ + Allows: true, + } + return s +} + +// MarshalJSON ensures that the unstructured object produces proper +// JSON when passed to Go's standard JSON library. +func (u *UnstructuredSpec) MarshalJSON() ([]byte, error) { + return json.Marshal(u.Object) +} + +// UnmarshalJSON ensures that the unstructured object properly decodes +// JSON when passed to Go's standard JSON library. +func (u *UnstructuredSpec) UnmarshalJSON(b []byte) error { + return json.Unmarshal(b, &u.Object) +} + +func (u *UnstructuredSpec) DeepCopy() *UnstructuredSpec { + if u == nil { + return nil + } + out := new(UnstructuredSpec) + *out = *u + + tmp := common.Unstructured{Object: u.Object} + copy := tmp.DeepCopy() + out.Object = copy.Object + return out +} + +func (u *UnstructuredSpec) DeepCopyInto(out *UnstructuredSpec) { + clone := u.DeepCopy() + *out = *clone +} diff --git a/pkg/apis/datasource/v0alpha1/zz_generated.deepcopy.go b/pkg/apis/datasource/v0alpha1/zz_generated.deepcopy.go index 5f9e41d7ad2..4488fa37f74 100644 --- a/pkg/apis/datasource/v0alpha1/zz_generated.deepcopy.go +++ b/pkg/apis/datasource/v0alpha1/zz_generated.deepcopy.go @@ -8,29 +8,38 @@ package v0alpha1 import ( + commonv0alpha1 "github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1" runtime "k8s.io/apimachinery/pkg/runtime" ) // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *DataSourceConnection) DeepCopyInto(out *DataSourceConnection) { +func (in *DataSource) DeepCopyInto(out *DataSource) { *out = *in out.TypeMeta = in.TypeMeta in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + in.Spec.DeepCopyInto(&out.Spec) + if in.Secure != nil { + in, out := &in.Secure, &out.Secure + *out = make(map[string]commonv0alpha1.InlineSecureValue, len(*in)) + for key, val := range *in { + (*out)[key] = val + } + } return } -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DataSourceConnection. -func (in *DataSourceConnection) DeepCopy() *DataSourceConnection { +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DataSource. +func (in *DataSource) DeepCopy() *DataSource { if in == nil { return nil } - out := new(DataSourceConnection) + out := new(DataSource) in.DeepCopyInto(out) return out } // DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. -func (in *DataSourceConnection) DeepCopyObject() runtime.Object { +func (in *DataSource) DeepCopyObject() runtime.Object { if c := in.DeepCopy(); c != nil { return c } @@ -38,13 +47,13 @@ func (in *DataSourceConnection) DeepCopyObject() runtime.Object { } // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *DataSourceConnectionList) DeepCopyInto(out *DataSourceConnectionList) { +func (in *DataSourceList) DeepCopyInto(out *DataSourceList) { *out = *in out.TypeMeta = in.TypeMeta in.ListMeta.DeepCopyInto(&out.ListMeta) if in.Items != nil { in, out := &in.Items, &out.Items - *out = make([]DataSourceConnection, len(*in)) + *out = make([]DataSource, len(*in)) for i := range *in { (*in)[i].DeepCopyInto(&(*out)[i]) } @@ -52,18 +61,18 @@ func (in *DataSourceConnectionList) DeepCopyInto(out *DataSourceConnectionList) return } -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DataSourceConnectionList. -func (in *DataSourceConnectionList) DeepCopy() *DataSourceConnectionList { +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DataSourceList. +func (in *DataSourceList) DeepCopy() *DataSourceList { if in == nil { return nil } - out := new(DataSourceConnectionList) + out := new(DataSourceList) in.DeepCopyInto(out) return out } // DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. -func (in *DataSourceConnectionList) DeepCopyObject() runtime.Object { +func (in *DataSourceList) DeepCopyObject() runtime.Object { if c := in.DeepCopy(); c != nil { return c } diff --git a/pkg/apis/datasource/v0alpha1/zz_generated.defaults.go b/pkg/apis/datasource/v0alpha1/zz_generated.defaults.go deleted file mode 100644 index 238fc2f4edc..00000000000 --- a/pkg/apis/datasource/v0alpha1/zz_generated.defaults.go +++ /dev/null @@ -1,19 +0,0 @@ -//go:build !ignore_autogenerated -// +build !ignore_autogenerated - -// SPDX-License-Identifier: AGPL-3.0-only - -// Code generated by defaulter-gen. DO NOT EDIT. - -package v0alpha1 - -import ( - runtime "k8s.io/apimachinery/pkg/runtime" -) - -// RegisterDefaults adds defaulters functions to the given scheme. -// Public to allow building arbitrary schemes. -// All generated defaulters are covering - they call all nested defaulters. -func RegisterDefaults(scheme *runtime.Scheme) error { - return nil -} diff --git a/pkg/apis/datasource/v0alpha1/zz_generated.openapi.go b/pkg/apis/datasource/v0alpha1/zz_generated.openapi.go index 8573e1080df..7e78e455bae 100644 --- a/pkg/apis/datasource/v0alpha1/zz_generated.openapi.go +++ b/pkg/apis/datasource/v0alpha1/zz_generated.openapi.go @@ -14,13 +14,15 @@ import ( func GetOpenAPIDefinitions(ref common.ReferenceCallback) map[string]common.OpenAPIDefinition { return map[string]common.OpenAPIDefinition{ - "github.com/grafana/grafana/pkg/apis/datasource/v0alpha1.DataSourceConnection": schema_pkg_apis_datasource_v0alpha1_DataSourceConnection(ref), - "github.com/grafana/grafana/pkg/apis/datasource/v0alpha1.DataSourceConnectionList": schema_pkg_apis_datasource_v0alpha1_DataSourceConnectionList(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.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.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(), } } -func schema_pkg_apis_datasource_v0alpha1_DataSourceConnection(ref common.ReferenceCallback) common.OpenAPIDefinition { +func schema_pkg_apis_datasource_v0alpha1_DataSource(ref common.ReferenceCallback) common.OpenAPIDefinition { return common.OpenAPIDefinition{ Schema: spec.Schema{ SchemaProps: spec.SchemaProps{ @@ -46,31 +48,37 @@ func schema_pkg_apis_datasource_v0alpha1_DataSourceConnection(ref common.Referen Ref: ref("k8s.io/apimachinery/pkg/apis/meta/v1.ObjectMeta"), }, }, - "title": { + "spec": { SchemaProps: spec.SchemaProps{ - Description: "The display name", - Default: "", - Type: []string{"string"}, - Format: "", + Description: "DataSource configuration -- these properties are all visible to anyone able to query the data source from their browser", + Ref: ref("github.com/grafana/grafana/pkg/apis/datasource/v0alpha1.UnstructuredSpec"), }, }, - "description": { + "secure": { SchemaProps: spec.SchemaProps{ - Description: "Optional description for the data source (does not exist yet)", - Type: []string{"string"}, - Format: "", + Description: "Secure values allows setting values that are never shown to users The returned properties are only the names of the configured values", + Type: []string{"object"}, + AdditionalProperties: &spec.SchemaOrBool{ + Allows: true, + Schema: &spec.Schema{ + SchemaProps: spec.SchemaProps{ + Default: map[string]interface{}{}, + Ref: ref("github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1.InlineSecureValue"), + }, + }, + }, }, }, }, - Required: []string{"title"}, + Required: []string{"metadata", "spec"}, }, }, Dependencies: []string{ - "k8s.io/apimachinery/pkg/apis/meta/v1.ObjectMeta"}, + "github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1.InlineSecureValue", "github.com/grafana/grafana/pkg/apis/datasource/v0alpha1.UnstructuredSpec", "k8s.io/apimachinery/pkg/apis/meta/v1.ObjectMeta"}, } } -func schema_pkg_apis_datasource_v0alpha1_DataSourceConnectionList(ref common.ReferenceCallback) common.OpenAPIDefinition { +func schema_pkg_apis_datasource_v0alpha1_DataSourceList(ref common.ReferenceCallback) common.OpenAPIDefinition { return common.OpenAPIDefinition{ Schema: spec.Schema{ SchemaProps: spec.SchemaProps{ @@ -103,18 +111,104 @@ func schema_pkg_apis_datasource_v0alpha1_DataSourceConnectionList(ref common.Ref Schema: &spec.Schema{ SchemaProps: spec.SchemaProps{ Default: map[string]interface{}{}, - Ref: ref("github.com/grafana/grafana/pkg/apis/datasource/v0alpha1.DataSourceConnection"), + Ref: ref("github.com/grafana/grafana/pkg/apis/datasource/v0alpha1.DataSource"), }, }, }, }, }, }, - Required: []string{"items"}, + Required: []string{"metadata", "items"}, }, }, Dependencies: []string{ - "github.com/grafana/grafana/pkg/apis/datasource/v0alpha1.DataSourceConnection", "k8s.io/apimachinery/pkg/apis/meta/v1.ListMeta"}, + "github.com/grafana/grafana/pkg/apis/datasource/v0alpha1.DataSource", "k8s.io/apimachinery/pkg/apis/meta/v1.ListMeta"}, + } +} + +func schema_pkg_apis_datasource_v0alpha1_GenericDataSourceSpec(ref common.ReferenceCallback) common.OpenAPIDefinition { + return common.OpenAPIDefinition{ + Schema: spec.Schema{ + SchemaProps: spec.SchemaProps{ + Type: []string{"object"}, + Properties: map[string]spec.Schema{ + "title": { + SchemaProps: spec.SchemaProps{ + Description: "The display name (previously saved as the \"name\" property)", + Default: "", + Type: []string{"string"}, + Format: "", + }, + }, + "access": { + SchemaProps: spec.SchemaProps{ + Description: "Possible enum values:\n - `\"direct\"` The frontend can connect directly to the remote URL This method is discouraged\n - `\"proxy\"` Connect to the remote datasource through the grafana backend", + Type: []string{"string"}, + Format: "", + Enum: []interface{}{"direct", "proxy"}, + }, + }, + "readOnly": { + SchemaProps: spec.SchemaProps{ + Type: []string{"boolean"}, + Format: "", + }, + }, + "isDefault": { + SchemaProps: spec.SchemaProps{ + Type: []string{"boolean"}, + Format: "", + }, + }, + "url": { + SchemaProps: spec.SchemaProps{ + Description: "Server URL", + Type: []string{"string"}, + Format: "", + }, + }, + "user": { + SchemaProps: spec.SchemaProps{ + Type: []string{"string"}, + Format: "", + }, + }, + "database": { + SchemaProps: spec.SchemaProps{ + Type: []string{"string"}, + Format: "", + }, + }, + "basicAuth": { + SchemaProps: spec.SchemaProps{ + Type: []string{"boolean"}, + Format: "", + }, + }, + "basicAuthUser": { + SchemaProps: spec.SchemaProps{ + Type: []string{"string"}, + Format: "", + }, + }, + "withCredentials": { + SchemaProps: spec.SchemaProps{ + Type: []string{"boolean"}, + Format: "", + }, + }, + "jsonData": { + SchemaProps: spec.SchemaProps{ + Description: "Generic unstructured configuration settings", + Ref: ref("github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1.Unstructured"), + }, + }, + }, + Required: []string{"title", "jsonData"}, + }, + }, + Dependencies: []string{ + "github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1.Unstructured"}, } } diff --git a/pkg/apis/datasource/v0alpha1/zz_generated.openapi_violation_exceptions.list b/pkg/apis/datasource/v0alpha1/zz_generated.openapi_violation_exceptions.list new file mode 100644 index 00000000000..918518966c7 --- /dev/null +++ b/pkg/apis/datasource/v0alpha1/zz_generated.openapi_violation_exceptions.list @@ -0,0 +1,2 @@ +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 diff --git a/pkg/apis/query/v0alpha1/datasource.go b/pkg/apis/query/v0alpha1/connection.go similarity index 54% rename from pkg/apis/query/v0alpha1/datasource.go rename to pkg/apis/query/v0alpha1/connection.go index 04c236113e3..ced60d0d106 100644 --- a/pkg/apis/query/v0alpha1/datasource.go +++ b/pkg/apis/query/v0alpha1/connection.go @@ -7,6 +7,40 @@ import ( "k8s.io/apimachinery/pkg/runtime/schema" ) +// Connection to a datasource instance +// The connection name must be '{group}:{name}' +// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object +type DataSourceConnection struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitzero,omitempty"` + + // The configured display name + Title string `json:"title"` + + // Reference to the kubernets datasource + Datasource DataSourceConnectionRef `json:"datasource"` +} + +type DataSourceConnectionRef struct { + Group string `json:"group"` + Version string `json:"version"` + Name string `json:"name"` +} + +// The valid connection name for a group + identifier +func DataSourceConnectionName(group, name string) string { + return group + ":" + name +} + +// List of all datasource instances across all datasource apiservers +// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object +type DataSourceConnectionList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitzero,omitempty"` + + Items []DataSourceConnection `json:"items"` +} + type DataSourceApiServerRegistry interface { // Get the group and preferred version for a plugin GetDatasourceGroupVersion(pluginId string) (schema.GroupVersion, error) @@ -24,7 +58,7 @@ type DataSourceApiServerRegistry interface { // +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object type DataSourceApiServer struct { metav1.TypeMeta `json:",inline"` - metav1.ObjectMeta `json:"metadata,omitempty"` + metav1.ObjectMeta `json:"metadata,omitzero,omitempty"` // The display name Title string `json:"title"` @@ -43,7 +77,7 @@ type DataSourceApiServer struct { // +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object type DataSourceApiServerList struct { metav1.TypeMeta `json:",inline"` - metav1.ListMeta `json:"metadata,omitempty"` + metav1.ListMeta `json:"metadata,omitzero,omitempty"` Items []DataSourceApiServer `json:"items"` } diff --git a/pkg/apis/query/v0alpha1/register.go b/pkg/apis/query/v0alpha1/register.go index 14ba3560315..6eb75675cdd 100644 --- a/pkg/apis/query/v0alpha1/register.go +++ b/pkg/apis/query/v0alpha1/register.go @@ -1,6 +1,10 @@ package v0alpha1 import ( + "fmt" + "time" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/runtime/schema" @@ -13,6 +17,32 @@ const ( APIVERSION = GROUP + "/" + VERSION ) +var ConnectionResourceInfo = utils.NewResourceInfo(GROUP, VERSION, + "connections", "connection", "DataSourceConnection", + func() runtime.Object { return &DataSourceConnection{} }, + func() runtime.Object { return &DataSourceConnectionList{} }, + utils.TableColumns{ + Definition: []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"}, + }, + Reader: func(obj any) ([]interface{}, error) { + m, ok := obj.(*DataSourceConnection) + if !ok { + return nil, fmt.Errorf("expected connection") + } + return []interface{}{ + m.Name, + m.Title, + m.APIVersion, + m.CreationTimestamp.UTC().Format(time.RFC3339), + }, nil + }, + }, +) + var DataSourceApiServerResourceInfo = utils.NewResourceInfo(GROUP, VERSION, "datasourceapiservers", "datasourceapiserver", "DataSourceApiServer", func() runtime.Object { return &DataSourceApiServer{} }, diff --git a/pkg/apis/query/v0alpha1/zz_generated.deepcopy.go b/pkg/apis/query/v0alpha1/zz_generated.deepcopy.go index 8f36003313d..38a7bd0b115 100644 --- a/pkg/apis/query/v0alpha1/zz_generated.deepcopy.go +++ b/pkg/apis/query/v0alpha1/zz_generated.deepcopy.go @@ -75,6 +75,82 @@ func (in *DataSourceApiServerList) DeepCopyObject() runtime.Object { return nil } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *DataSourceConnection) DeepCopyInto(out *DataSourceConnection) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + out.Datasource = in.Datasource + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DataSourceConnection. +func (in *DataSourceConnection) DeepCopy() *DataSourceConnection { + if in == nil { + return nil + } + out := new(DataSourceConnection) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *DataSourceConnection) 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 *DataSourceConnectionList) DeepCopyInto(out *DataSourceConnectionList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]DataSourceConnection, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DataSourceConnectionList. +func (in *DataSourceConnectionList) DeepCopy() *DataSourceConnectionList { + if in == nil { + return nil + } + out := new(DataSourceConnectionList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *DataSourceConnectionList) 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 *DataSourceConnectionRef) DeepCopyInto(out *DataSourceConnectionRef) { + *out = *in + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DataSourceConnectionRef. +func (in *DataSourceConnectionRef) DeepCopy() *DataSourceConnectionRef { + if in == nil { + return nil + } + out := new(DataSourceConnectionRef) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *QueryDataRequest) DeepCopyInto(out *QueryDataRequest) { *out = *in diff --git a/pkg/apis/query/v0alpha1/zz_generated.openapi.go b/pkg/apis/query/v0alpha1/zz_generated.openapi.go index b4089bd94f1..2ee9ed95395 100644 --- a/pkg/apis/query/v0alpha1/zz_generated.openapi.go +++ b/pkg/apis/query/v0alpha1/zz_generated.openapi.go @@ -14,12 +14,15 @@ import ( func GetOpenAPIDefinitions(ref common.ReferenceCallback) map[string]common.OpenAPIDefinition { return map[string]common.OpenAPIDefinition{ - "github.com/grafana/grafana/pkg/apis/query/v0alpha1.DataSourceApiServer": schema_pkg_apis_query_v0alpha1_DataSourceApiServer(ref), - "github.com/grafana/grafana/pkg/apis/query/v0alpha1.DataSourceApiServerList": schema_pkg_apis_query_v0alpha1_DataSourceApiServerList(ref), - "github.com/grafana/grafana/pkg/apis/query/v0alpha1.QueryDataRequest": schema_pkg_apis_query_v0alpha1_QueryDataRequest(ref), - "github.com/grafana/grafana/pkg/apis/query/v0alpha1.QueryDataResponse": schema_pkg_apis_query_v0alpha1_QueryDataResponse(ref), - "github.com/grafana/grafana/pkg/apis/query/v0alpha1.QueryTypeDefinition": schema_pkg_apis_query_v0alpha1_QueryTypeDefinition(ref), - "github.com/grafana/grafana/pkg/apis/query/v0alpha1.QueryTypeDefinitionList": schema_pkg_apis_query_v0alpha1_QueryTypeDefinitionList(ref), + "github.com/grafana/grafana/pkg/apis/query/v0alpha1.DataSourceApiServer": schema_pkg_apis_query_v0alpha1_DataSourceApiServer(ref), + "github.com/grafana/grafana/pkg/apis/query/v0alpha1.DataSourceApiServerList": schema_pkg_apis_query_v0alpha1_DataSourceApiServerList(ref), + "github.com/grafana/grafana/pkg/apis/query/v0alpha1.DataSourceConnection": schema_pkg_apis_query_v0alpha1_DataSourceConnection(ref), + "github.com/grafana/grafana/pkg/apis/query/v0alpha1.DataSourceConnectionList": schema_pkg_apis_query_v0alpha1_DataSourceConnectionList(ref), + "github.com/grafana/grafana/pkg/apis/query/v0alpha1.DataSourceConnectionRef": schema_pkg_apis_query_v0alpha1_DataSourceConnectionRef(ref), + "github.com/grafana/grafana/pkg/apis/query/v0alpha1.QueryDataRequest": schema_pkg_apis_query_v0alpha1_QueryDataRequest(ref), + "github.com/grafana/grafana/pkg/apis/query/v0alpha1.QueryDataResponse": schema_pkg_apis_query_v0alpha1_QueryDataResponse(ref), + "github.com/grafana/grafana/pkg/apis/query/v0alpha1.QueryTypeDefinition": schema_pkg_apis_query_v0alpha1_QueryTypeDefinition(ref), + "github.com/grafana/grafana/pkg/apis/query/v0alpha1.QueryTypeDefinitionList": schema_pkg_apis_query_v0alpha1_QueryTypeDefinitionList(ref), } } @@ -146,6 +149,140 @@ func schema_pkg_apis_query_v0alpha1_DataSourceApiServerList(ref common.Reference } } +func schema_pkg_apis_query_v0alpha1_DataSourceConnection(ref common.ReferenceCallback) common.OpenAPIDefinition { + return common.OpenAPIDefinition{ + Schema: spec.Schema{ + SchemaProps: spec.SchemaProps{ + Description: "Connection to a datasource instance The connection name must be '{group}:{name}'", + 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: "", + }, + }, + "metadata": { + SchemaProps: spec.SchemaProps{ + Default: map[string]interface{}{}, + Ref: ref("k8s.io/apimachinery/pkg/apis/meta/v1.ObjectMeta"), + }, + }, + "title": { + SchemaProps: spec.SchemaProps{ + Description: "The configured display name", + Default: "", + Type: []string{"string"}, + Format: "", + }, + }, + "datasource": { + SchemaProps: spec.SchemaProps{ + Description: "Reference to the kubernets datasource", + Default: map[string]interface{}{}, + Ref: ref("github.com/grafana/grafana/pkg/apis/query/v0alpha1.DataSourceConnectionRef"), + }, + }, + }, + Required: []string{"title", "datasource"}, + }, + }, + Dependencies: []string{ + "github.com/grafana/grafana/pkg/apis/query/v0alpha1.DataSourceConnectionRef", "k8s.io/apimachinery/pkg/apis/meta/v1.ObjectMeta"}, + } +} + +func schema_pkg_apis_query_v0alpha1_DataSourceConnectionList(ref common.ReferenceCallback) common.OpenAPIDefinition { + return common.OpenAPIDefinition{ + Schema: spec.Schema{ + SchemaProps: spec.SchemaProps{ + Description: "List of all datasource instances across all datasource apiservers", + 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: "", + }, + }, + "metadata": { + SchemaProps: spec.SchemaProps{ + Default: map[string]interface{}{}, + Ref: ref("k8s.io/apimachinery/pkg/apis/meta/v1.ListMeta"), + }, + }, + "items": { + SchemaProps: spec.SchemaProps{ + Type: []string{"array"}, + Items: &spec.SchemaOrArray{ + Schema: &spec.Schema{ + SchemaProps: spec.SchemaProps{ + Default: map[string]interface{}{}, + Ref: ref("github.com/grafana/grafana/pkg/apis/query/v0alpha1.DataSourceConnection"), + }, + }, + }, + }, + }, + }, + Required: []string{"items"}, + }, + }, + Dependencies: []string{ + "github.com/grafana/grafana/pkg/apis/query/v0alpha1.DataSourceConnection", "k8s.io/apimachinery/pkg/apis/meta/v1.ListMeta"}, + } +} + +func schema_pkg_apis_query_v0alpha1_DataSourceConnectionRef(ref common.ReferenceCallback) common.OpenAPIDefinition { + return common.OpenAPIDefinition{ + Schema: spec.Schema{ + SchemaProps: spec.SchemaProps{ + Type: []string{"object"}, + Properties: map[string]spec.Schema{ + "group": { + SchemaProps: spec.SchemaProps{ + Default: "", + Type: []string{"string"}, + Format: "", + }, + }, + "version": { + SchemaProps: spec.SchemaProps{ + Default: "", + Type: []string{"string"}, + Format: "", + }, + }, + "name": { + SchemaProps: spec.SchemaProps{ + Default: "", + Type: []string{"string"}, + Format: "", + }, + }, + }, + Required: []string{"group", "version", "name"}, + }, + }, + } +} + func schema_pkg_apis_query_v0alpha1_QueryDataRequest(ref common.ReferenceCallback) common.OpenAPIDefinition { return common.OpenAPIDefinition{ Schema: spec.Schema{ diff --git a/pkg/apis/query/v0alpha1/zz_generated.openapi_violation_exceptions.list b/pkg/apis/query/v0alpha1/zz_generated.openapi_violation_exceptions.list index 85c5a768907..f2a822d96dc 100644 --- a/pkg/apis/query/v0alpha1/zz_generated.openapi_violation_exceptions.list +++ b/pkg/apis/query/v0alpha1/zz_generated.openapi_violation_exceptions.list @@ -1 +1,3 @@ API rule violation: list_type_missing,github.com/grafana/grafana/pkg/apis/query/v0alpha1,DataSourceApiServer,AliasIDs +API rule violation: streaming_list_type_json_tags,github.com/grafana/grafana/pkg/apis/query/v0alpha1,DataSourceApiServerList,ListMeta +API rule violation: streaming_list_type_json_tags,github.com/grafana/grafana/pkg/apis/query/v0alpha1,DataSourceConnectionList,ListMeta diff --git a/pkg/extensions/enterprise_imports.go b/pkg/extensions/enterprise_imports.go index 504d1378bc7..d33ccf561aa 100644 --- a/pkg/extensions/enterprise_imports.go +++ b/pkg/extensions/enterprise_imports.go @@ -53,5 +53,4 @@ import ( _ "github.com/grafana/e2e" _ "github.com/grafana/gofpdf" _ "github.com/grafana/gomemcache/memcache" - _ "github.com/grafana/tempo/pkg/traceql" ) diff --git a/pkg/registry/apis/datasource/authorizer.go b/pkg/registry/apis/datasource/authorizer.go index 570b438d808..d862655c749 100644 --- a/pkg/registry/apis/datasource/authorizer.go +++ b/pkg/registry/apis/datasource/authorizer.go @@ -25,7 +25,7 @@ func (b *DataSourceAPIBuilder) GetAuthorizer() authorizer.Authorizer { uidScope := datasources.ScopeProvider.GetResourceScopeUID(attr.GetName()) // Must have query access to see a connection - if attr.GetResource() == b.connectionResourceInfo.GroupResource().Resource { + if attr.GetResource() == b.datasourceResourceInfo.GroupResource().Resource { scopes := []string{} if attr.GetName() != "" { scopes = []string{uidScope} diff --git a/pkg/registry/apis/datasource/connections.go b/pkg/registry/apis/datasource/connections.go deleted file mode 100644 index ec79393095d..00000000000 --- a/pkg/registry/apis/datasource/connections.go +++ /dev/null @@ -1,59 +0,0 @@ -package datasource - -import ( - "context" - - "github.com/grafana/grafana/pkg/apimachinery/utils" - "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" -) - -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 utils.ResourceInfo - tableConverter rest.TableConvertor - datasources PluginDatasourceProvider -} - -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) { - return s.datasources.Get(ctx, name) -} - -func (s *connectionAccess) List(ctx context.Context, options *internalversion.ListOptions) (runtime.Object, error) { - return s.datasources.List(ctx) -} diff --git a/pkg/registry/apis/datasource/converter.go b/pkg/registry/apis/datasource/converter.go new file mode 100644 index 00000000000..f1db08f42b5 --- /dev/null +++ b/pkg/registry/apis/datasource/converter.go @@ -0,0 +1,192 @@ +package datasource + +import ( + "crypto/sha256" + "encoding/hex" + "fmt" + "iter" + "maps" + "slices" + "strconv" + "strings" + "time" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + "github.com/grafana/authlib/types" + common "github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1" + "github.com/grafana/grafana/pkg/apimachinery/utils" + datasourceV0 "github.com/grafana/grafana/pkg/apis/datasource/v0alpha1" + "github.com/grafana/grafana/pkg/components/simplejson" + "github.com/grafana/grafana/pkg/services/apiserver/endpoints/request" + gapiutil "github.com/grafana/grafana/pkg/services/apiserver/utils" + "github.com/grafana/grafana/pkg/services/datasources" +) + +type converter struct { + mapper request.NamespaceMapper + group string // the expected group + plugin string // the expected pluginId + alias []string // optional alias for the pluginId +} + +func (r *converter) asDataSource(ds *datasources.DataSource) (*datasourceV0.DataSource, error) { + if ds.Type != r.plugin && !slices.Contains(r.alias, ds.Type) { + return nil, fmt.Errorf("expected datasource type: %s %v // not: %s", r.plugin, r.alias, ds.Type) + } + + obj := &datasourceV0.DataSource{ + ObjectMeta: metav1.ObjectMeta{ + Name: ds.UID, + Namespace: r.mapper(ds.OrgID), + Generation: int64(ds.Version), + }, + Spec: datasourceV0.UnstructuredSpec{}, + Secure: ToInlineSecureValues(ds.Type, ds.UID, maps.Keys(ds.SecureJsonData)), + } + obj.UID = gapiutil.CalculateClusterWideUID(obj) + obj.Spec.SetTitle(ds.Name). + SetAccess(string(ds.Access)). + SetURL(ds.URL). + SetDatabase(ds.Database). + SetUser(ds.User). + SetDatabase(ds.Database). + SetBasicAuth(ds.BasicAuth). + SetBasicAuthUser(ds.BasicAuthUser). + SetWithCredentials(ds.WithCredentials). + SetIsDefault(ds.IsDefault). + SetReadOnly(ds.ReadOnly). + SetJSONData(ds.JsonData) + + if !ds.Created.IsZero() { + obj.CreationTimestamp = metav1.NewTime(ds.Created) + } + if !ds.Updated.IsZero() { + obj.ResourceVersion = fmt.Sprintf("%d", ds.Updated.UnixMilli()) + obj.Annotations = map[string]string{ + utils.AnnoKeyUpdatedTimestamp: ds.Updated.Format(time.RFC3339), + } + } + + if ds.APIVersion != "" { + obj.APIVersion = fmt.Sprintf("%s/%s", r.group, ds.APIVersion) + } + + if ds.ID > 0 { + obj.Labels = map[string]string{ + utils.LabelKeyDeprecatedInternalID: strconv.FormatInt(ds.ID, 10), + } + } + return obj, nil +} + +// ToInlineSecureValues converts secure json into InlineSecureValues with reference names +// The names are predictable and can be used while we implement dual writing for secrets +func ToInlineSecureValues(dsType string, dsUID string, keys iter.Seq[string]) common.InlineSecureValues { + values := make(common.InlineSecureValues) + for k := range keys { + h := sha256.New() + h.Write([]byte(dsType)) // plugin id + h.Write([]byte("|")) + h.Write([]byte(dsUID)) // unique identifier + h.Write([]byte("|")) + h.Write([]byte(k)) // property name + n := hex.EncodeToString(h.Sum(nil)) + values[k] = common.InlineSecureValue{ + Name: "ds-" + n[0:10], // predictable name for dual writing + } + } + if len(values) == 0 { + return nil + } + return values +} + +func (r *converter) toAddCommand(ds *datasourceV0.DataSource) (*datasources.AddDataSourceCommand, error) { + if r.group != "" && ds.APIVersion != "" && !strings.HasPrefix(ds.APIVersion, r.group) { + return nil, fmt.Errorf("expecting APIGroup: %s", r.group) + } + info, err := types.ParseNamespace(ds.Namespace) + if err != nil { + return nil, err + } + + cmd := &datasources.AddDataSourceCommand{ + Name: ds.Spec.Title(), + UID: ds.Name, + OrgID: info.OrgID, + Type: r.plugin, + + Access: datasources.DsAccess(ds.Spec.Access()), + URL: ds.Spec.URL(), + Database: ds.Spec.Database(), + User: ds.Spec.User(), + BasicAuth: ds.Spec.BasicAuth(), + BasicAuthUser: ds.Spec.BasicAuthUser(), + WithCredentials: ds.Spec.WithCredentials(), + IsDefault: ds.Spec.IsDefault(), + ReadOnly: ds.Spec.ReadOnly(), + } + + jsonData := ds.Spec.JSONData() + if jsonData != nil { + cmd.JsonData = simplejson.NewFromAny(jsonData) + } + + cmd.SecureJsonData = toSecureJsonData(ds) + return cmd, nil +} + +func (r *converter) toUpdateCommand(ds *datasourceV0.DataSource) (*datasources.UpdateDataSourceCommand, error) { + if r.group != "" && ds.APIVersion != "" && !strings.HasPrefix(ds.APIVersion, r.group) { + return nil, fmt.Errorf("expecting APIGroup: %s", r.group) + } + info, err := types.ParseNamespace(ds.Namespace) + if err != nil { + return nil, err + } + + cmd := &datasources.UpdateDataSourceCommand{ + Name: ds.Spec.Title(), + UID: ds.Name, + OrgID: info.OrgID, + Type: r.plugin, + + Access: datasources.DsAccess(ds.Spec.Access()), + URL: ds.Spec.URL(), + Database: ds.Spec.Database(), + User: ds.Spec.User(), + BasicAuth: ds.Spec.BasicAuth(), + BasicAuthUser: ds.Spec.BasicAuthUser(), + WithCredentials: ds.Spec.WithCredentials(), + IsDefault: ds.Spec.IsDefault(), + ReadOnly: ds.Spec.ReadOnly(), + + // The only field different than add + Version: int(ds.Generation), + } + + jsonData := ds.Spec.JSONData() + if jsonData != nil { + cmd.JsonData = simplejson.NewFromAny(jsonData) + } + cmd.SecureJsonData = toSecureJsonData(ds) + return cmd, err +} + +func toSecureJsonData(ds *datasourceV0.DataSource) map[string]string { + if ds == nil || len(ds.Secure) < 1 { + return nil + } + + secure := map[string]string{} + for k, v := range ds.Secure { + if v.Create != "" { + secure[k] = v.Create.DangerouslyExposeAndConsumeValue() + } + if v.Remove { + secure[k] = "" // Weirdly, this is the best we can do with the legacy API :( + } + } + return secure +} diff --git a/pkg/registry/apis/datasource/converter_test.go b/pkg/registry/apis/datasource/converter_test.go new file mode 100644 index 00000000000..e5953928f88 --- /dev/null +++ b/pkg/registry/apis/datasource/converter_test.go @@ -0,0 +1,153 @@ +package datasource + +import ( + "encoding/json" + "errors" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/grafana/authlib/types" + "github.com/grafana/grafana/pkg/apis/datasource/v0alpha1" + "github.com/grafana/grafana/pkg/services/datasources" +) + +func TestConverter(t *testing.T) { + t.Run("resource to command", func(t *testing.T) { + converter := converter{ + mapper: types.OrgNamespaceFormatter, + plugin: "grafana-testdata-datasource", + alias: []string{"testdata"}, + group: "testdata.grafana.datasource.app", + } + tests := []struct { + name string + expectedErr string + }{ + {"convert-resource-full", ""}, + {"convert-resource-empty", ""}, + {"convert-resource-invalid", "expecting APIGroup: testdata.grafana.datasource.app"}, + {"convert-resource-invalid2", "invalid stack id"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + obj := &v0alpha1.DataSource{} + fpath := filepath.Join("testdata", tt.name+".json") + raw, err := os.ReadFile(fpath) // nolint:gosec + require.NoError(t, err) + err = json.Unmarshal(raw, obj) + require.NoError(t, err) + + // The add command + fpath = filepath.Join("testdata", tt.name+"-to-cmd-add.json") + add, err := converter.toAddCommand(obj) + if tt.expectedErr != "" { + require.ErrorContains(t, err, tt.expectedErr) + require.Nil(t, add, "cmd should be nil when error exists") + + update, err := converter.toUpdateCommand(obj) + require.ErrorContains(t, err, tt.expectedErr) + require.Nil(t, update, "cmd should be nil when error exists") + return + } + + require.NoError(t, err) + out, err := json.MarshalIndent(add, "", " ") + require.NoError(t, err) + raw, _ = os.ReadFile(fpath) // nolint:gosec + if !assert.JSONEq(t, string(raw), string(out)) { + _ = os.WriteFile(fpath, out, 0600) + } + + // The update command + fpath = filepath.Join("testdata", tt.name+"-to-cmd-update.json") + update, err := converter.toUpdateCommand(obj) + require.NoError(t, err) + + out, err = json.MarshalIndent(update, "", " ") + require.NoError(t, err) + raw, _ = os.ReadFile(fpath) // nolint:gosec + if !assert.JSONEq(t, string(raw), string(out)) { + _ = os.WriteFile(fpath, out, 0600) + } + + // Round trip the update (NOTE, not all properties will be included) + ds := &datasources.DataSource{} + err = json.Unmarshal(raw, ds) // the add command is also a DataSource + require.NoError(t, err) + + roundtrip, err := converter.asDataSource(ds) + require.NoError(t, err) + + fpath = filepath.Join("testdata", tt.name+"-to-cmd-update-roundtrip.json") + out, err = json.MarshalIndent(roundtrip, "", " ") + require.NoError(t, err) + raw, _ = os.ReadFile(fpath) // nolint:gosec + if !assert.JSONEq(t, string(raw), string(out)) { + _ = os.WriteFile(fpath, out, 0600) + } + }) + } + }) + + t.Run("dto to resource", func(t *testing.T) { + converter := converter{ + mapper: types.OrgNamespaceFormatter, + plugin: "grafana-testdata-datasource", + alias: []string{"testdata"}, + group: "testdata.grafana.datasource.app", + } + tests := []struct { + name string + expectedErr string + }{ + { + name: "convert-dto-testdata", + }, + { + name: "convert-dto-empty", + }, + { + name: "convert-dto-invalid", + expectedErr: "expected datasource type: grafana-testdata-datasource [testdata]", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ds := &datasources.DataSource{} + fpath := filepath.Join("testdata", tt.name+".json") + raw, err := os.ReadFile(fpath) // nolint:gosec + require.NoError(t, err) + err = json.Unmarshal(raw, ds) + require.NoError(t, err) + + obj, err := converter.asDataSource(ds) + if tt.expectedErr != "" { + require.ErrorContains(t, err, tt.expectedErr) + require.Nil(t, obj, "object should be nil when error exists") + } else { + require.NoError(t, err) + } + + // Verify the result + fpath = filepath.Join("testdata", tt.name+"-to-resource.json") + if obj == nil { + _, err := os.Stat(fpath) + require.Error(t, err, "file should not exist") + require.True(t, errors.Is(err, os.ErrNotExist)) + } else { + out, err := json.MarshalIndent(obj, "", " ") + require.NoError(t, err) + raw, _ = os.ReadFile(fpath) // nolint:gosec + if !assert.JSONEq(t, string(raw), string(out)) { + _ = os.WriteFile(fpath, out, 0600) + } + } + }) + } + }) +} diff --git a/pkg/registry/apis/datasource/legacy_store.go b/pkg/registry/apis/datasource/legacy_store.go new file mode 100644 index 00000000000..8786dc8d86e --- /dev/null +++ b/pkg/registry/apis/datasource/legacy_store.go @@ -0,0 +1,126 @@ +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/apimachinery/utils" + "github.com/grafana/grafana/pkg/apis/datasource/v0alpha1" +) + +var ( + _ rest.Scoper = (*legacyStorage)(nil) + _ rest.SingularNameProvider = (*legacyStorage)(nil) + _ rest.Getter = (*legacyStorage)(nil) + _ rest.Lister = (*legacyStorage)(nil) + _ rest.Storage = (*legacyStorage)(nil) + _ rest.Creater = (*legacyStorage)(nil) + _ rest.Updater = (*legacyStorage)(nil) + _ rest.GracefulDeleter = (*legacyStorage)(nil) + _ rest.CollectionDeleter = (*legacyStorage)(nil) +) + +type legacyStorage struct { + datasources PluginDatasourceProvider + resourceInfo *utils.ResourceInfo +} + +func (s *legacyStorage) New() runtime.Object { + return s.resourceInfo.NewFunc() +} + +func (s *legacyStorage) Destroy() {} + +func (s *legacyStorage) NamespaceScoped() bool { + return true // namespace == org +} + +func (s *legacyStorage) GetSingularName() string { + return s.resourceInfo.GetSingularName() +} + +func (s *legacyStorage) NewList() runtime.Object { + return s.resourceInfo.NewListFunc() +} + +func (s *legacyStorage) ConvertToTable(ctx context.Context, object runtime.Object, tableOptions runtime.Object) (*metav1.Table, error) { + return s.resourceInfo.TableConverter().ConvertToTable(ctx, object, tableOptions) +} + +func (s *legacyStorage) List(ctx context.Context, options *internalversion.ListOptions) (runtime.Object, error) { + return s.datasources.ListDataSources(ctx) +} + +func (s *legacyStorage) Get(ctx context.Context, name string, options *metav1.GetOptions) (runtime.Object, error) { + return s.datasources.GetDataSource(ctx, name) +} + +// Create implements rest.Creater. +func (s *legacyStorage) Create(ctx context.Context, obj runtime.Object, createValidation rest.ValidateObjectFunc, options *metav1.CreateOptions) (runtime.Object, error) { + ds, ok := obj.(*v0alpha1.DataSource) + if !ok { + return nil, fmt.Errorf("expected a datasource object") + } + return s.datasources.CreateDataSource(ctx, ds) +} + +// Update implements rest.Updater. +func (s *legacyStorage) Update(ctx context.Context, name string, objInfo rest.UpdatedObjectInfo, createValidation rest.ValidateObjectFunc, updateValidation rest.ValidateObjectUpdateFunc, forceAllowCreate bool, options *metav1.UpdateOptions) (runtime.Object, bool, error) { + old, err := s.Get(ctx, name, &metav1.GetOptions{}) + if err != nil { + return nil, false, err + } + + obj, err := objInfo.UpdatedObject(ctx, old) + if err != nil { + return nil, false, err + } + + ds, ok := obj.(*v0alpha1.DataSource) + if !ok { + return nil, false, fmt.Errorf("expected a datasource object") + } + + oldDS, ok := obj.(*v0alpha1.DataSource) + if !ok { + return nil, false, fmt.Errorf("expected a datasource object (old)") + } + + // Keep all the old secure values + if len(oldDS.Secure) > 0 { + for k, v := range oldDS.Secure { + _, found := ds.Secure[k] + if !found { + ds.Secure[k] = v + } + } + } + + ds, err = s.datasources.UpdateDataSource(ctx, ds) + return ds, false, err +} + +// Delete implements rest.GracefulDeleter. +func (s *legacyStorage) Delete(ctx context.Context, name string, deleteValidation rest.ValidateObjectFunc, options *metav1.DeleteOptions) (runtime.Object, bool, error) { + err := s.datasources.DeleteDataSource(ctx, name) + return nil, false, err +} + +// DeleteCollection implements rest.CollectionDeleter. +func (s *legacyStorage) DeleteCollection(ctx context.Context, deleteValidation rest.ValidateObjectFunc, options *metav1.DeleteOptions, listOptions *internalversion.ListOptions) (runtime.Object, error) { + dss, err := s.datasources.ListDataSources(ctx) + if err != nil { + return nil, err + } + for _, ds := range dss.Items { + if err = s.datasources.DeleteDataSource(ctx, ds.Name); err != nil { + return nil, err + } + } + return nil, nil +} diff --git a/pkg/registry/apis/datasource/noop.go b/pkg/registry/apis/datasource/noop.go new file mode 100644 index 00000000000..9bff0298db6 --- /dev/null +++ b/pkg/registry/apis/datasource/noop.go @@ -0,0 +1,42 @@ +package datasource + +import ( + "context" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apiserver/pkg/registry/rest" + + query "github.com/grafana/grafana/pkg/apis/query/v0alpha1" +) + +// Temporary noop storage that lets us map /connections/{name}/query +type noopREST struct{} + +var ( + _ rest.Storage = (*noopREST)(nil) + _ rest.Scoper = (*noopREST)(nil) + _ rest.Getter = (*noopREST)(nil) + _ rest.SingularNameProvider = (*noopREST)(nil) +) + +func (r *noopREST) New() runtime.Object { + return &query.QueryDataResponse{} +} + +func (r *noopREST) Destroy() {} + +func (r *noopREST) NamespaceScoped() bool { + return true +} + +func (r *noopREST) GetSingularName() string { + return "noop" +} + +func (r *noopREST) Get(ctx context.Context, name string, options *metav1.GetOptions) (runtime.Object, error) { + return &metav1.Status{ + Status: metav1.StatusSuccess, + Message: "noop", + }, nil +} diff --git a/pkg/registry/apis/datasource/openapi.go b/pkg/registry/apis/datasource/openapi.go new file mode 100644 index 00000000000..ab63c29e819 --- /dev/null +++ b/pkg/registry/apis/datasource/openapi.go @@ -0,0 +1,74 @@ +package datasource + +import ( + "fmt" + + "k8s.io/kube-openapi/pkg/spec3" + "k8s.io/kube-openapi/pkg/validation/spec" + + "github.com/grafana/grafana/pkg/registry/apis/query/queryschema" +) + +func (b *DataSourceAPIBuilder) PostProcessOpenAPI(oas *spec3.OpenAPI) (*spec3.OpenAPI, error) { + // The plugin description + oas.Info.Description = b.pluginJSON.Info.Description + + // The root api URL + root := "/apis/" + b.datasourceResourceInfo.GroupVersion().String() + "/" + + // Add queries to the request properties + if err := queryschema.AddQueriesToOpenAPI(queryschema.OASQueryOptions{ + Swagger: oas, + PluginJSON: &b.pluginJSON, + QueryTypes: b.queryTypes, + Root: root, + QueryPath: "namespaces/{namespace}/datasources/{name}/query", + QueryDescription: fmt.Sprintf("Query the %s datasources", b.pluginJSON.Name), + }); err != nil { + return nil, err + } + + // Hide the resource routes -- explicit ones will be added if defined below + prefix := root + "namespaces/{namespace}/datasources/{name}/resource" + r := oas.Paths.Paths[prefix] + if r != nil && r.Get != nil { + r.Get.Description = "Get resources in the datasource plugin. NOTE, additional routes may exist, but are not exposed via OpenAPI" + r.Delete = nil + r.Head = nil + r.Patch = nil + r.Post = nil + r.Put = nil + r.Options = nil + } + delete(oas.Paths.Paths, prefix+"/{path}") + + // Set explicit apiVersion and kind on the datasource + ds, ok := oas.Components.Schemas["com.github.grafana.grafana.pkg.apis.datasource.v0alpha1.DataSource"] + if !ok { + return nil, fmt.Errorf("missing DS type") + } + ds.Properties["apiVersion"] = *spec.StringProperty().WithEnum(b.GetGroupVersion().String()) + ds.Properties["kind"] = *spec.StringProperty().WithEnum("DataSource") + + // Mark connections as deprecated + delete(oas.Paths.Paths, root+"namespaces/{namespace}/connections/{name}") + query := oas.Paths.Paths[root+"namespaces/{namespace}/connections/{name}/query"] + for query == nil || query.Post == nil { + return nil, fmt.Errorf("missing temporary connection path") + } + query.Post.Tags = []string{"Connections (deprecated)"} + query.Post.Deprecated = true + query.Post.RequestBody = &spec3.RequestBody{ + RequestBodyProps: spec3.RequestBodyProps{ + Content: map[string]*spec3.MediaType{ + "application/json": { + MediaTypeProps: spec3.MediaTypeProps{ + Schema: spec.MapProperty(nil), + }, + }, + }, + }, + } + + return oas, nil +} diff --git a/pkg/registry/apis/datasource/plugincontext.go b/pkg/registry/apis/datasource/plugincontext.go index aa9c01954da..a0525030dcb 100644 --- a/pkg/registry/apis/datasource/plugincontext.go +++ b/pkg/registry/apis/datasource/plugincontext.go @@ -5,27 +5,33 @@ import ( "fmt" "github.com/grafana/grafana-plugin-sdk-go/backend" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "github.com/grafana/grafana/pkg/apimachinery/identity" - "github.com/grafana/grafana/pkg/apimachinery/utils" - "github.com/grafana/grafana/pkg/apis/datasource/v0alpha1" + datasourceV0 "github.com/grafana/grafana/pkg/apis/datasource/v0alpha1" "github.com/grafana/grafana/pkg/plugins" "github.com/grafana/grafana/pkg/services/apiserver/endpoints/request" - gapiutil "github.com/grafana/grafana/pkg/services/apiserver/utils" "github.com/grafana/grafana/pkg/services/datasources" "github.com/grafana/grafana/pkg/services/pluginsintegration/plugincontext" + "github.com/grafana/grafana/pkg/setting" ) // This provides access to settings saved in the database. // Authorization checks will happen within each function, and the user in ctx will // limit which namespace/tenant/org we are talking to type PluginDatasourceProvider interface { - // Get gets a specific datasource (that the user in context can see) - Get(ctx context.Context, uid string) (*v0alpha1.DataSourceConnection, error) + // Get a single data source (any type) + GetDataSource(ctx context.Context, uid string) (*datasourceV0.DataSource, error) - // List lists all data sources the user in context can see - List(ctx context.Context) (*v0alpha1.DataSourceConnectionList, error) + // List all datasources (any type) + ListDataSources(ctx context.Context) (*datasourceV0.DataSourceList, error) + + // Create a data source + CreateDataSource(ctx context.Context, ds *datasourceV0.DataSource) (*datasourceV0.DataSource, error) + + // Update a data source + UpdateDataSource(ctx context.Context, ds *datasourceV0.DataSource) (*datasourceV0.DataSource, error) + + // Delete a data source (any type) + DeleteDataSource(ctx context.Context, uid string) error // Return settings (decrypted!) for a specific plugin // This will require "query" permission for the user in context @@ -44,11 +50,16 @@ type PluginContextWrapper interface { func ProvideDefaultPluginConfigs( dsService datasources.DataSourceService, dsCache datasources.CacheService, - contextProvider *plugincontext.Provider) ScopedPluginDatasourceProvider { + contextProvider *plugincontext.Provider, + cfg *setting.Cfg, +) ScopedPluginDatasourceProvider { return &cachingDatasourceProvider{ dsService: dsService, dsCache: dsCache, contextProvider: contextProvider, + converter: &converter{ + mapper: request.GetNamespaceMapper(cfg), + }, } } @@ -56,14 +67,22 @@ type cachingDatasourceProvider struct { dsService datasources.DataSourceService dsCache datasources.CacheService contextProvider *plugincontext.Provider + converter *converter } func (q *cachingDatasourceProvider) GetDatasourceProvider(pluginJson plugins.JSONData) PluginDatasourceProvider { + group, _ := plugins.GetDatasourceGroupNameFromPluginID(pluginJson.ID) return &scopedDatasourceProvider{ plugin: pluginJson, dsService: q.dsService, dsCache: q.dsCache, contextProvider: q.contextProvider, + converter: &converter{ + mapper: q.converter.mapper, + plugin: pluginJson.ID, + alias: pluginJson.AliasIDs, + group: group, + }, } } @@ -72,6 +91,7 @@ type scopedDatasourceProvider struct { dsService datasources.DataSourceService dsCache datasources.CacheService contextProvider *plugincontext.Provider + converter *converter } var ( @@ -79,11 +99,62 @@ var ( _ ScopedPluginDatasourceProvider = (*cachingDatasourceProvider)(nil) ) -func (q *scopedDatasourceProvider) Get(ctx context.Context, uid string) (*v0alpha1.DataSourceConnection, error) { - info, err := request.NamespaceInfoFrom(ctx, true) +func (q *scopedDatasourceProvider) GetInstanceSettings(ctx context.Context, uid string) (*backend.DataSourceInstanceSettings, error) { + if q.contextProvider == nil { + return nil, fmt.Errorf("missing contextProvider") + } + return q.contextProvider.GetDataSourceInstanceSettings(ctx, uid) +} + +// CreateDataSource implements PluginDatasourceProvider. +func (q *scopedDatasourceProvider) CreateDataSource(ctx context.Context, ds *datasourceV0.DataSource) (*datasourceV0.DataSource, error) { + cmd, err := q.converter.toAddCommand(ds) if err != nil { return nil, err } + out, err := q.dsService.AddDataSource(ctx, cmd) + if err != nil { + return nil, err + } + return q.converter.asDataSource(out) +} + +// UpdateDataSource implements PluginDatasourceProvider. +func (q *scopedDatasourceProvider) UpdateDataSource(ctx context.Context, ds *datasourceV0.DataSource) (*datasourceV0.DataSource, error) { + cmd, err := q.converter.toUpdateCommand(ds) + if err != nil { + return nil, err + } + out, err := q.dsService.UpdateDataSource(ctx, cmd) + if err != nil { + return nil, err + } + return q.converter.asDataSource(out) +} + +// Delete implements PluginDatasourceProvider. +func (q *scopedDatasourceProvider) DeleteDataSource(ctx context.Context, uid string) error { + user, err := identity.GetRequester(ctx) + if err != nil { + return err + } + ds, err := q.dsCache.GetDatasourceByUID(ctx, uid, user, false) + if err != nil { + return err + } + if ds == nil { + return fmt.Errorf("not found") + } + return q.dsService.DeleteDataSource(ctx, &datasources.DeleteDataSourceCommand{ + ID: ds.ID, + UID: ds.UID, + OrgID: ds.OrgID, + Name: ds.Name, + }) +} + +// GetDataSource implements PluginDatasourceProvider. +func (q *scopedDatasourceProvider) GetDataSource(ctx context.Context, uid string) (*datasourceV0.DataSource, error) { user, err := identity.GetRequester(ctx) if err != nil { return nil, err @@ -92,10 +163,11 @@ func (q *scopedDatasourceProvider) Get(ctx context.Context, uid string) (*v0alph if err != nil { return nil, err } - return asConnection(ds, info.Value) + return q.converter.asDataSource(ds) } -func (q *scopedDatasourceProvider) List(ctx context.Context) (*v0alpha1.DataSourceConnectionList, error) { +// ListDataSource implements PluginDatasourceProvider. +func (q *scopedDatasourceProvider) ListDataSources(ctx context.Context) (*datasourceV0.DataSourceList, error) { info, err := request.NamespaceInfoFrom(ctx, true) if err != nil { return nil, err @@ -109,37 +181,12 @@ func (q *scopedDatasourceProvider) List(ctx context.Context) (*v0alpha1.DataSour if err != nil { return nil, err } - result := &v0alpha1.DataSourceConnectionList{ - Items: []v0alpha1.DataSourceConnection{}, + result := &datasourceV0.DataSourceList{ + Items: []datasourceV0.DataSource{}, } for _, ds := range dss { - v, _ := asConnection(ds, info.Value) + v, _ := q.converter.asDataSource(ds) result.Items = append(result.Items, *v) } return result, nil } - -func (q *scopedDatasourceProvider) GetInstanceSettings(ctx context.Context, uid string) (*backend.DataSourceInstanceSettings, error) { - if q.contextProvider == nil { - return nil, fmt.Errorf("missing contextProvider") - } - return q.contextProvider.GetDataSourceInstanceSettings(ctx, uid) -} - -func asConnection(ds *datasources.DataSource, ns string) (*v0alpha1.DataSourceConnection, error) { - v := &v0alpha1.DataSourceConnection{ - ObjectMeta: metav1.ObjectMeta{ - Name: ds.UID, - Namespace: ns, - CreationTimestamp: metav1.NewTime(ds.Created), - ResourceVersion: fmt.Sprintf("%d", ds.Updated.UnixMilli()), - }, - Title: ds.Name, - } - v.UID = gapiutil.CalculateClusterWideUID(v) // indicates if the value changed on the server - meta, err := utils.MetaAccessor(v) - if err != nil { - meta.SetUpdatedTimestamp(&ds.Updated) - } - return v, err -} diff --git a/pkg/registry/apis/datasource/querier.go b/pkg/registry/apis/datasource/querier.go index c18e3ec1b57..5ab7b5e5a5e 100644 --- a/pkg/registry/apis/datasource/querier.go +++ b/pkg/registry/apis/datasource/querier.go @@ -4,13 +4,10 @@ import ( "context" "github.com/grafana/grafana-plugin-sdk-go/backend" - "github.com/grafana/grafana/pkg/apimachinery/identity" "github.com/grafana/grafana/pkg/apimachinery/utils" - "github.com/grafana/grafana/pkg/apis/datasource/v0alpha1" "github.com/grafana/grafana/pkg/plugins" "github.com/grafana/grafana/pkg/services/apiserver/endpoints/request" "github.com/grafana/grafana/pkg/services/datasources" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) type QuerierFactoryFunc func(ctx context.Context, ri utils.ResourceInfo, pj plugins.JSONData) (Querier, error) @@ -48,10 +45,6 @@ type Querier interface { Health(ctx context.Context, req *backend.CheckHealthRequest) (*backend.CheckHealthResult, error) // Resource gets a resource plugin. Resource(ctx context.Context, req *backend.CallResourceRequest, sender backend.CallResourceResponseSender) error - // Datasource gets all data source plugins (with elevated permissions). - Datasource(ctx context.Context, name string) (*v0alpha1.DataSourceConnection, error) - // Datasources lists all data sources (with elevated permissions). - Datasources(ctx context.Context) (*v0alpha1.DataSourceConnectionList, error) } type DefaultQuerier struct { @@ -101,47 +94,3 @@ func (q *DefaultQuerier) Health(ctx context.Context, query *backend.CheckHealthR } return q.pluginClient.CheckHealth(ctx, query) } - -func (q *DefaultQuerier) Datasource(ctx context.Context, name string) (*v0alpha1.DataSourceConnection, error) { - info, err := request.NamespaceInfoFrom(ctx, true) - if err != nil { - return nil, err - } - user, err := identity.GetRequester(ctx) - if err != nil { - return nil, err - } - ds, err := q.dsCache.GetDatasourceByUID(ctx, name, user, false) - if err != nil { - return nil, err - } - return asConnection(ds, info.Value) -} - -func (q *DefaultQuerier) Datasources(ctx context.Context) (*v0alpha1.DataSourceConnectionList, error) { - info, err := request.NamespaceInfoFrom(ctx, true) - if err != nil { - return nil, err - } - - ds, err := q.dsService.GetDataSourcesByType(ctx, &datasources.GetDataSourcesByTypeQuery{ - OrgID: info.OrgID, - Type: q.pluginJSON.ID, - }) - if err != nil { - return nil, err - } - return asConnectionList(q.connectionResourceInfo.TypeMeta(), ds, info.Value) -} - -func asConnectionList(typeMeta metav1.TypeMeta, dss []*datasources.DataSource, ns string) (*v0alpha1.DataSourceConnectionList, error) { - result := &v0alpha1.DataSourceConnectionList{ - Items: []v0alpha1.DataSourceConnection{}, - } - for _, ds := range dss { - v, _ := asConnection(ds, ns) - result.Items = append(result.Items, *v) - } - - return result, nil -} diff --git a/pkg/registry/apis/datasource/register.go b/pkg/registry/apis/datasource/register.go index dc31d8cbd9e..dd1b69d971f 100644 --- a/pkg/registry/apis/datasource/register.go +++ b/pkg/registry/apis/datasource/register.go @@ -3,7 +3,7 @@ package datasource import ( "context" "encoding/json" - "fmt" + "maps" "github.com/prometheus/client_golang/prometheus" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -13,13 +13,13 @@ import ( "k8s.io/apiserver/pkg/registry/rest" genericapiserver "k8s.io/apiserver/pkg/server" openapi "k8s.io/kube-openapi/pkg/common" - "k8s.io/kube-openapi/pkg/spec3" "k8s.io/utils/strings/slices" "github.com/grafana/grafana-plugin-sdk-go/backend" "github.com/grafana/grafana/pkg/apimachinery/utils" - datasource "github.com/grafana/grafana/pkg/apis/datasource/v0alpha1" - query "github.com/grafana/grafana/pkg/apis/query/v0alpha1" + datasourceV0 "github.com/grafana/grafana/pkg/apis/datasource/v0alpha1" + queryV0 "github.com/grafana/grafana/pkg/apis/query/v0alpha1" + grafanaregistry "github.com/grafana/grafana/pkg/apiserver/registry/generic" "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/plugins" "github.com/grafana/grafana/pkg/promlib/models" @@ -31,18 +31,22 @@ import ( "github.com/grafana/grafana/pkg/tsdb/grafana-testdata-datasource/kinds" ) -var _ builder.APIGroupBuilder = (*DataSourceAPIBuilder)(nil) +var ( + _ builder.APIGroupBuilder = (*DataSourceAPIBuilder)(nil) + // _ builder.APIGroupMutation = (*DataSourceAPIBuilder)(nil) + // _ builder.APIGroupValidation = (*DataSourceAPIBuilder)(nil) +) // DataSourceAPIBuilder is used just so wire has something unique to return type DataSourceAPIBuilder struct { - connectionResourceInfo utils.ResourceInfo + datasourceResourceInfo utils.ResourceInfo pluginJSON plugins.JSONData - client PluginClient // will only ever be called with the same pluginid! + client PluginClient // will only ever be called with the same plugin id! datasources PluginDatasourceProvider contextProvider PluginContextWrapper accessControl accesscontrol.AccessControl - queryTypes *query.QueryTypeDefinitionList + queryTypes *queryV0.QueryTypeDefinitionList log log.Logger } @@ -92,6 +96,12 @@ func RegisterAPIService( if err != nil { return nil, err } + + // TODO: load the schema provider from a static manifest + // if ds.ID == "grafana-testdata-datasource" { + // builder.schemaProvider = hardcoded.TestdataOpenAPIExtension + // } + apiRegistrar.RegisterAPI(builder) } return builder, nil // only used for wire @@ -114,13 +124,13 @@ func NewDataSourceAPIBuilder( accessControl accesscontrol.AccessControl, loadQueryTypes bool, ) (*DataSourceAPIBuilder, error) { - ri, err := resourceFromPluginID(plugin.ID) + group, err := plugins.GetDatasourceGroupNameFromPluginID(plugin.ID) if err != nil { return nil, err } builder := &DataSourceAPIBuilder{ - connectionResourceInfo: ri, + datasourceResourceInfo: datasourceV0.DataSourceResourceInfo.WithGroupAndShortName(group, plugin.ID), pluginJSON: plugin, client: client, datasources: datasources, @@ -130,13 +140,13 @@ func NewDataSourceAPIBuilder( } if loadQueryTypes { // In the future, this will somehow come from the plugin - builder.queryTypes, err = getHardcodedQueryTypes(ri.GroupResource().Group) + builder.queryTypes, err = getHardcodedQueryTypes(group) } return builder, err } // TODO -- somehow get the list from the plugin -- not hardcoded -func getHardcodedQueryTypes(group string) (*query.QueryTypeDefinitionList, error) { +func getHardcodedQueryTypes(group string) (*queryV0.QueryTypeDefinitionList, error) { var err error var raw json.RawMessage switch group { @@ -149,7 +159,7 @@ func getHardcodedQueryTypes(group string) (*query.QueryTypeDefinitionList, error return nil, err } if raw != nil { - types := &query.QueryTypeDefinitionList{} + types := &queryV0.QueryTypeDefinitionList{} err = json.Unmarshal(raw, types) return types, err } @@ -157,26 +167,27 @@ func getHardcodedQueryTypes(group string) (*query.QueryTypeDefinitionList, error } func (b *DataSourceAPIBuilder) GetGroupVersion() schema.GroupVersion { - return b.connectionResourceInfo.GroupVersion() + return b.datasourceResourceInfo.GroupVersion() } func addKnownTypes(scheme *runtime.Scheme, gv schema.GroupVersion) { scheme.AddKnownTypes(gv, - &datasource.DataSourceConnection{}, - &datasource.DataSourceConnectionList{}, - &datasource.HealthCheckResult{}, + &datasourceV0.DataSource{}, + &datasourceV0.DataSourceList{}, + &datasourceV0.HealthCheckResult{}, &unstructured.Unstructured{}, + // Query handler - &query.QueryDataRequest{}, - &query.QueryDataResponse{}, - &query.QueryTypeDefinition{}, - &query.QueryTypeDefinitionList{}, + &queryV0.QueryDataRequest{}, + &queryV0.QueryDataResponse{}, + &queryV0.QueryTypeDefinition{}, + &queryV0.QueryTypeDefinitionList{}, &metav1.Status{}, ) } func (b *DataSourceAPIBuilder) InstallSchema(scheme *runtime.Scheme) error { - gv := b.connectionResourceInfo.GroupVersion() + gv := b.datasourceResourceInfo.GroupVersion() addKnownTypes(scheme, gv) // Link this version to the internal representation. @@ -199,43 +210,48 @@ func (b *DataSourceAPIBuilder) AllowedV0Alpha1Resources() []string { return []string{builder.AllResourcesAllowed} } -func resourceFromPluginID(pluginID string) (utils.ResourceInfo, error) { - group, err := plugins.GetDatasourceGroupNameFromPluginID(pluginID) - if err != nil { - return utils.ResourceInfo{}, err - } - return datasource.GenericConnectionResourceInfo.WithGroupAndShortName(group, pluginID+"-connection"), nil -} - -func (b *DataSourceAPIBuilder) UpdateAPIGroupInfo(apiGroupInfo *genericapiserver.APIGroupInfo, _ builder.APIGroupOptions) error { +func (b *DataSourceAPIBuilder) UpdateAPIGroupInfo(apiGroupInfo *genericapiserver.APIGroupInfo, opts builder.APIGroupOptions) error { storage := map[string]rest.Storage{} - conn := b.connectionResourceInfo - storage[conn.StoragePath()] = &connectionAccess{ - datasources: b.datasources, - resourceInfo: conn, - tableConverter: conn.TableConverter(), + // Register the raw datasource connection + ds := b.datasourceResourceInfo + legacyStore := &legacyStorage{ + datasources: b.datasources, + resourceInfo: &ds, + } + unified, err := grafanaregistry.NewRegistryStore(opts.Scheme, ds, opts.OptsGetter) + if err != nil { + return err + } + storage[ds.StoragePath()], err = opts.DualWriteBuilder(ds.GroupResource(), legacyStore, unified) + if err != nil { + return err } - 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} + storage[ds.StoragePath("query")] = &subQueryREST{builder: b} + storage[ds.StoragePath("health")] = &subHealthREST{builder: b} + storage[ds.StoragePath("resource")] = &subResourceREST{builder: b} + + // FIXME: temporarily register both "datasources" and "connections" query paths + // This lets us deploy both datasources/{uid}/query and connections/{uid}/query + // while we transition requests to the new path + storage["connections"] = &noopREST{} // hidden from openapi + storage["connections/query"] = storage[ds.StoragePath("query")] // deprecated in openapi // Frontend proxy if len(b.pluginJSON.Routes) > 0 { - storage[conn.StoragePath("proxy")] = &subProxyREST{pluginJSON: b.pluginJSON} + storage[ds.StoragePath("proxy")] = &subProxyREST{pluginJSON: b.pluginJSON} } // Register hardcoded query schemas - err := queryschema.RegisterQueryTypes(b.queryTypes, storage) + err = queryschema.RegisterQueryTypes(b.queryTypes, storage) if err != nil { return err } registerQueryConvert(b.client, b.contextProvider, storage) - apiGroupInfo.VersionedResourcesStorageMap[conn.GroupVersion().Version] = storage + apiGroupInfo.VersionedResourcesStorageMap[ds.GroupVersion().Version] = storage return err } @@ -249,31 +265,8 @@ func (b *DataSourceAPIBuilder) getPluginContext(ctx context.Context, uid string) func (b *DataSourceAPIBuilder) GetOpenAPIDefinitions() openapi.GetOpenAPIDefinitions { return func(ref openapi.ReferenceCallback) map[string]openapi.OpenAPIDefinition { - defs := query.GetOpenAPIDefinitions(ref) // required when running standalone - for k, v := range datasource.GetOpenAPIDefinitions(ref) { - defs[k] = v - } + defs := queryV0.GetOpenAPIDefinitions(ref) // required when running standalone + maps.Copy(defs, datasourceV0.GetOpenAPIDefinitions(ref)) return defs } } - -func (b *DataSourceAPIBuilder) PostProcessOpenAPI(oas *spec3.OpenAPI) (*spec3.OpenAPI, error) { - // The plugin description - oas.Info.Description = b.pluginJSON.Info.Description - - // The root api URL - root := "/apis/" + b.connectionResourceInfo.GroupVersion().String() + "/" - - // Add queries to the request properties - // Add queries to the request properties - err := queryschema.AddQueriesToOpenAPI(queryschema.OASQueryOptions{ - Swagger: oas, - PluginJSON: &b.pluginJSON, - QueryTypes: b.queryTypes, - Root: root, - QueryPath: "namespaces/{namespace}/connections/{name}/query", - QueryDescription: fmt.Sprintf("Query the %s datasources", b.pluginJSON.Name), - }) - - return oas, err -} diff --git a/pkg/registry/apis/datasource/sub_query.go b/pkg/registry/apis/datasource/sub_query.go index 51f8b90d430..c7bab42f950 100644 --- a/pkg/registry/apis/datasource/sub_query.go +++ b/pkg/registry/apis/datasource/sub_query.go @@ -6,18 +6,16 @@ import ( "fmt" "net/http" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apiserver/pkg/registry/rest" + "github.com/grafana/grafana-plugin-sdk-go/backend" data "github.com/grafana/grafana-plugin-sdk-go/experimental/apis/data/v0alpha1" query "github.com/grafana/grafana/pkg/apis/query/v0alpha1" query_headers "github.com/grafana/grafana/pkg/registry/apis/query" "github.com/grafana/grafana/pkg/services/datasources" - "k8s.io/apimachinery/pkg/runtime" - "k8s.io/apimachinery/pkg/runtime/schema" - "k8s.io/apiserver/pkg/registry/rest" "github.com/grafana/grafana/pkg/web" - - k8serrors "k8s.io/apimachinery/pkg/api/errors" ) type subQueryREST struct { @@ -28,6 +26,7 @@ var ( _ rest.Storage = (*subQueryREST)(nil) _ rest.Connecter = (*subQueryREST)(nil) _ rest.StorageMetadata = (*subQueryREST)(nil) + _ rest.Scoper = (*subQueryREST)(nil) ) func (r *subQueryREST) New() runtime.Object { @@ -37,6 +36,10 @@ func (r *subQueryREST) New() runtime.Object { func (r *subQueryREST) Destroy() {} +func (r *subQueryREST) NamespaceScoped() bool { + return true +} + func (r *subQueryREST) ProducesMIMETypes(verb string) []string { return []string{"application/json"} // and parquet! } @@ -58,15 +61,8 @@ func (r *subQueryREST) Connect(ctx context.Context, name string, opts runtime.Ob if err != nil { if errors.Is(err, datasources.ErrDataSourceNotFound) { - return nil, k8serrors.NewNotFound( - schema.GroupResource{ - Group: r.builder.connectionResourceInfo.GroupResource().Group, - Resource: r.builder.connectionResourceInfo.GroupResource().Resource, - }, - name, - ) + return nil, r.builder.datasourceResourceInfo.NewNotFound(name) } - return nil, err } diff --git a/pkg/registry/apis/datasource/sub_query_test.go b/pkg/registry/apis/datasource/sub_query_test.go index 6b3dc54b970..57aa7626aba 100644 --- a/pkg/registry/apis/datasource/sub_query_test.go +++ b/pkg/registry/apis/datasource/sub_query_test.go @@ -8,14 +8,16 @@ import ( "net/http/httptest" "testing" - "github.com/grafana/grafana-plugin-sdk-go/backend" - "github.com/grafana/grafana/pkg/apis/datasource/v0alpha1" - "github.com/grafana/grafana/pkg/infra/log" - "github.com/grafana/grafana/pkg/services/datasources" - "github.com/grafana/grafana/pkg/services/ngalert/models" "github.com/stretchr/testify/require" k8serrors "k8s.io/apimachinery/pkg/api/errors" "k8s.io/apimachinery/pkg/runtime" + + "github.com/grafana/grafana-plugin-sdk-go/backend" + "github.com/grafana/grafana/pkg/apis/datasource/v0alpha1" + queryV0 "github.com/grafana/grafana/pkg/apis/query/v0alpha1" + "github.com/grafana/grafana/pkg/infra/log" + "github.com/grafana/grafana/pkg/services/datasources" + "github.com/grafana/grafana/pkg/services/ngalert/models" ) func TestSubQueryConnect(t *testing.T) { @@ -115,16 +117,43 @@ func (m mockResponder) Object(statusCode int, obj runtime.Object) { func (m mockResponder) Error(err error) { } +var _ PluginDatasourceProvider = (*mockDatasources)(nil) + type mockDatasources struct { } +// CreateDataSource implements PluginDatasourceProvider. +func (m mockDatasources) CreateDataSource(ctx context.Context, ds *v0alpha1.DataSource) (*v0alpha1.DataSource, error) { + return nil, nil +} + +// UpdateDataSource implements PluginDatasourceProvider. +func (m mockDatasources) UpdateDataSource(ctx context.Context, ds *v0alpha1.DataSource) (*v0alpha1.DataSource, error) { + return nil, nil +} + +// Delete implements PluginDatasourceProvider. +func (m mockDatasources) DeleteDataSource(ctx context.Context, uid string) error { + return nil +} + +// GetDataSource implements PluginDatasourceProvider. +func (m mockDatasources) GetDataSource(ctx context.Context, uid string) (*v0alpha1.DataSource, error) { + return nil, nil +} + +// ListDataSource implements PluginDatasourceProvider. +func (m mockDatasources) ListDataSources(ctx context.Context) (*v0alpha1.DataSourceList, error) { + return nil, nil +} + // Get gets a specific datasource (that the user in context can see) -func (m mockDatasources) Get(ctx context.Context, uid string) (*v0alpha1.DataSourceConnection, error) { +func (m mockDatasources) GetConnection(ctx context.Context, uid string) (*queryV0.DataSourceConnection, error) { return nil, nil } // List lists all data sources the user in context can see -func (m mockDatasources) List(ctx context.Context) (*v0alpha1.DataSourceConnectionList, error) { +func (m mockDatasources) ListConnections(ctx context.Context) (*queryV0.DataSourceConnectionList, error) { return nil, nil } diff --git a/pkg/registry/apis/datasource/sub_resource.go b/pkg/registry/apis/datasource/sub_resource.go index 93027767158..f6d3fc04b96 100644 --- a/pkg/registry/apis/datasource/sub_resource.go +++ b/pkg/registry/apis/datasource/sub_resource.go @@ -8,11 +8,11 @@ import ( "net/url" "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-plugin-sdk-go/backend" "github.com/grafana/grafana/pkg/plugins/httpresponsesender" ) diff --git a/pkg/registry/apis/datasource/sub_resource_test.go b/pkg/registry/apis/datasource/sub_resource_test.go index 3900a34a732..bf66d52b6d2 100644 --- a/pkg/registry/apis/datasource/sub_resource_test.go +++ b/pkg/registry/apis/datasource/sub_resource_test.go @@ -18,36 +18,36 @@ func TestResourceRequest(t *testing.T) { }{ { desc: "no resource path", - url: "http://localhost:6443/apis/test.datasource.grafana.app/v0alpha1/namespaces/default/connections/abc", + url: "http://localhost:6443/apis/test.datasource.grafana.app/v0alpha1/namespaces/default/datasources/abc", error: true, }, { desc: "root resource path", - url: "http://localhost:6443/apis/test.datasource.grafana.app/v0alpha1/namespaces/default/connections/abc/resource", + url: "http://localhost:6443/apis/test.datasource.grafana.app/v0alpha1/namespaces/default/datasources/abc/resource", expectedPath: "", expectedURL: "", }, { desc: "root resource path", - url: "http://localhost:6443/apis/test.datasource.grafana.app/v0alpha1/namespaces/default/connections/abc/resource/", + url: "http://localhost:6443/apis/test.datasource.grafana.app/v0alpha1/namespaces/default/datasources/abc/resource/", expectedPath: "", expectedURL: "", }, { desc: "resource sub path", - url: "http://localhost:6443/apis/test.datasource.grafana.app/v0alpha1/namespaces/default/connections/abc/resource/test", + url: "http://localhost:6443/apis/test.datasource.grafana.app/v0alpha1/namespaces/default/datasources/abc/resource/test", expectedPath: "test", expectedURL: "test", }, { desc: "resource sub path with colon", - url: "http://localhost:6443/apis/test.datasource.grafana.app/v0alpha1/namespaces/default/connections/abc/resource/test-*,*:test-*/_mapping", + url: "http://localhost:6443/apis/test.datasource.grafana.app/v0alpha1/namespaces/default/datasources/abc/resource/test-*,*:test-*/_mapping", expectedPath: "test-*,*:test-*/_mapping", expectedURL: "./test-%2A,%2A:test-%2A/_mapping", }, { desc: "resource sub path with query params", - url: "http://localhost:6443/apis/test.datasource.grafana.app/v0alpha1/namespaces/default/connections/abc/resource/test?k1=v1&k2=v2", + url: "http://localhost:6443/apis/test.datasource.grafana.app/v0alpha1/namespaces/default/datasources/abc/resource/test?k1=v1&k2=v2", expectedPath: "test", expectedURL: "test?k1=v1&k2=v2", }, diff --git a/pkg/registry/apis/datasource/testdata/convert-dto-empty-to-resource.json b/pkg/registry/apis/datasource/testdata/convert-dto-empty-to-resource.json new file mode 100644 index 00000000000..4b35a3e1da8 --- /dev/null +++ b/pkg/registry/apis/datasource/testdata/convert-dto-empty-to-resource.json @@ -0,0 +1,16 @@ +{ + "metadata": { + "name": "unique-identifier", + "namespace": "org-0", + "uid": "YpaSG5GQAdxtLZtF6BqQWCeYXOhbVi5C4Cg4oILnJC0X", + "generation": 8, + "creationTimestamp": "2002-03-04T01:00:00Z", + "labels": { + "grafana.app/deprecatedInternalID": "456" + } + }, + "spec": { + "jsonData": null, + "title": "Display name" + } +} \ No newline at end of file diff --git a/pkg/registry/apis/datasource/testdata/convert-dto-empty.json b/pkg/registry/apis/datasource/testdata/convert-dto-empty.json new file mode 100644 index 00000000000..f19a05a3f4a --- /dev/null +++ b/pkg/registry/apis/datasource/testdata/convert-dto-empty.json @@ -0,0 +1,8 @@ +{ + "id": 456, + "version": 8, + "name": "Display name", + "uid": "unique-identifier", + "type": "grafana-testdata-datasource", + "created": "2002-03-04T01:00:00Z" +} \ No newline at end of file diff --git a/pkg/registry/apis/datasource/testdata/convert-dto-invalid.json b/pkg/registry/apis/datasource/testdata/convert-dto-invalid.json new file mode 100644 index 00000000000..6e5a5ca2ee2 --- /dev/null +++ b/pkg/registry/apis/datasource/testdata/convert-dto-invalid.json @@ -0,0 +1,8 @@ +{ + "id": 456, + "version": 8, + "name": "Hello", + "uid": "unique-identifier", + "type": "not-valid-plugin", + "created": "2002-03-04T01:00:00Z" +} \ No newline at end of file diff --git a/pkg/registry/apis/datasource/testdata/convert-dto-testdata-to-resource.json b/pkg/registry/apis/datasource/testdata/convert-dto-testdata-to-resource.json new file mode 100644 index 00000000000..0bfdaf4eb02 --- /dev/null +++ b/pkg/registry/apis/datasource/testdata/convert-dto-testdata-to-resource.json @@ -0,0 +1,39 @@ +{ + "apiVersion": "testdata.grafana.datasource.app/v2alpha1", + "metadata": { + "name": "unique-identifier", + "namespace": "org-0", + "uid": "YpaSG5GQAdxtLZtF6BqQWCeYXOhbVi5C4Cg4oILnJC0X", + "resourceVersion": "1083805200000", + "generation": 2, + "creationTimestamp": "2002-03-04T01:00:00Z", + "labels": { + "grafana.app/deprecatedInternalID": "1234" + }, + "annotations": { + "grafana.app/updatedTimestamp": "2004-05-06T01:00:00Z" + } + }, + "spec": { + "access": "proxy", + "basicAuth": true, + "basicAuthUser": "xxx", + "database": "db", + "isDefault": true, + "jsonData": { + "aaa": "bbb", + "bbb": true, + "ccc": 1.234 + }, + "readOnly": true, + "title": "Hello", + "url": "http://something/", + "user": "A", + "withCredentials": true + }, + "secure": { + "password": { + "name": "ds-d5c1b093af" + } + } +} \ No newline at end of file diff --git a/pkg/registry/apis/datasource/testdata/convert-dto-testdata.json b/pkg/registry/apis/datasource/testdata/convert-dto-testdata.json new file mode 100644 index 00000000000..8dd66e43875 --- /dev/null +++ b/pkg/registry/apis/datasource/testdata/convert-dto-testdata.json @@ -0,0 +1,27 @@ +{ + "id": 1234, + "version": 2, + "name": "Hello", + "uid": "unique-identifier", + "type": "grafana-testdata-datasource", + "access": "proxy", + "url": "http://something/", + "user": "A", + "database": "db", + "basicAuth": true, + "basicAuthUser": "xxx", + "withCredentials": true, + "isDefault": true, + "jsonData": { + "aaa": "bbb", + "bbb": true, + "ccc": 1.234 + }, + "secureJsonData": { + "password": "XXXX" + }, + "readOnly": true, + "apiVersion": "v2alpha1", + "created": "2002-03-04T01:00:00Z", + "updated": "2004-05-06T01:00:00Z" +} \ No newline at end of file diff --git a/pkg/registry/apis/datasource/testdata/convert-resource-empty-to-cmd-add.json b/pkg/registry/apis/datasource/testdata/convert-resource-empty-to-cmd-add.json new file mode 100644 index 00000000000..fc8787b0fb3 --- /dev/null +++ b/pkg/registry/apis/datasource/testdata/convert-resource-empty-to-cmd-add.json @@ -0,0 +1,15 @@ +{ + "name": "Hello testdata", + "type": "grafana-testdata-datasource", + "access": "", + "url": "", + "user": "", + "database": "", + "basicAuth": false, + "basicAuthUser": "", + "withCredentials": false, + "isDefault": false, + "jsonData": null, + "secureJsonData": null, + "uid": "cejobd88i85j4d" +} \ No newline at end of file diff --git a/pkg/registry/apis/datasource/testdata/convert-resource-empty-to-cmd-update-roundtrip.json b/pkg/registry/apis/datasource/testdata/convert-resource-empty-to-cmd-update-roundtrip.json new file mode 100644 index 00000000000..4b16a1455a2 --- /dev/null +++ b/pkg/registry/apis/datasource/testdata/convert-resource-empty-to-cmd-update-roundtrip.json @@ -0,0 +1,12 @@ +{ + "metadata": { + "name": "cejobd88i85j4d", + "namespace": "org-0", + "uid": "boDNh7zU3nXj46rOXIJI7r44qaxjs8yy9I9dOj1MyBoX", + "creationTimestamp": null + }, + "spec": { + "jsonData": null, + "title": "Hello testdata" + } +} \ No newline at end of file diff --git a/pkg/registry/apis/datasource/testdata/convert-resource-empty-to-cmd-update.json b/pkg/registry/apis/datasource/testdata/convert-resource-empty-to-cmd-update.json new file mode 100644 index 00000000000..3d5cf24700a --- /dev/null +++ b/pkg/registry/apis/datasource/testdata/convert-resource-empty-to-cmd-update.json @@ -0,0 +1,16 @@ +{ + "name": "Hello testdata", + "type": "grafana-testdata-datasource", + "access": "", + "url": "", + "user": "", + "database": "", + "basicAuth": false, + "basicAuthUser": "", + "withCredentials": false, + "isDefault": false, + "jsonData": null, + "secureJsonData": null, + "uid": "cejobd88i85j4d", + "version": 0 +} \ No newline at end of file diff --git a/pkg/registry/apis/datasource/testdata/convert-resource-empty.json b/pkg/registry/apis/datasource/testdata/convert-resource-empty.json new file mode 100644 index 00000000000..55a9d7fb1a6 --- /dev/null +++ b/pkg/registry/apis/datasource/testdata/convert-resource-empty.json @@ -0,0 +1,8 @@ +{ + "metadata": { + "name": "cejobd88i85j4d" + }, + "spec": { + "title": "Hello testdata" + } +} \ No newline at end of file diff --git a/pkg/registry/apis/datasource/testdata/convert-resource-full-to-cmd-add.json b/pkg/registry/apis/datasource/testdata/convert-resource-full-to-cmd-add.json new file mode 100644 index 00000000000..83e06abc5b2 --- /dev/null +++ b/pkg/registry/apis/datasource/testdata/convert-resource-full-to-cmd-add.json @@ -0,0 +1,22 @@ +{ + "name": "Hello testdata", + "type": "grafana-testdata-datasource", + "access": "proxy", + "url": "http://something/", + "user": "", + "database": "db", + "basicAuth": true, + "basicAuthUser": "xxx", + "withCredentials": true, + "isDefault": true, + "jsonData": { + "aaa": "bbb", + "bbb": true, + "ccc": 1.234 + }, + "secureJsonData": { + "extra": "", + "password": "XXXX" + }, + "uid": "cejobd88i85j4d" +} \ No newline at end of file diff --git a/pkg/registry/apis/datasource/testdata/convert-resource-full-to-cmd-update-roundtrip.json b/pkg/registry/apis/datasource/testdata/convert-resource-full-to-cmd-update-roundtrip.json new file mode 100644 index 00000000000..85535306cb0 --- /dev/null +++ b/pkg/registry/apis/datasource/testdata/convert-resource-full-to-cmd-update-roundtrip.json @@ -0,0 +1,32 @@ +{ + "metadata": { + "name": "cejobd88i85j4d", + "namespace": "org-0", + "uid": "boDNh7zU3nXj46rOXIJI7r44qaxjs8yy9I9dOj1MyBoX", + "generation": 2, + "creationTimestamp": null + }, + "spec": { + "access": "proxy", + "basicAuth": true, + "basicAuthUser": "xxx", + "database": "db", + "isDefault": true, + "jsonData": { + "aaa": "bbb", + "bbb": true, + "ccc": 1.234 + }, + "title": "Hello testdata", + "url": "http://something/", + "withCredentials": true + }, + "secure": { + "extra": { + "name": "ds-bb8b5d8b32" + }, + "password": { + "name": "ds-973a1eb29d" + } + } +} \ No newline at end of file diff --git a/pkg/registry/apis/datasource/testdata/convert-resource-full-to-cmd-update.json b/pkg/registry/apis/datasource/testdata/convert-resource-full-to-cmd-update.json new file mode 100644 index 00000000000..13304fc001a --- /dev/null +++ b/pkg/registry/apis/datasource/testdata/convert-resource-full-to-cmd-update.json @@ -0,0 +1,23 @@ +{ + "name": "Hello testdata", + "type": "grafana-testdata-datasource", + "access": "proxy", + "url": "http://something/", + "user": "", + "database": "db", + "basicAuth": true, + "basicAuthUser": "xxx", + "withCredentials": true, + "isDefault": true, + "jsonData": { + "aaa": "bbb", + "bbb": true, + "ccc": 1.234 + }, + "secureJsonData": { + "extra": "", + "password": "XXXX" + }, + "uid": "cejobd88i85j4d", + "version": 2 +} \ No newline at end of file diff --git a/pkg/registry/apis/datasource/testdata/convert-resource-full.json b/pkg/registry/apis/datasource/testdata/convert-resource-full.json new file mode 100644 index 00000000000..53c7086435a --- /dev/null +++ b/pkg/registry/apis/datasource/testdata/convert-resource-full.json @@ -0,0 +1,33 @@ +{ + "metadata": { + "name": "cejobd88i85j4d", + "namespace": "default", + "uid": "IGIUtEQS21DtLpBG2rSGfuDoUX8cwsGrtb5aXauYeA4X", + "resourceVersion": "1745320815000", + "generation": 2, + "creationTimestamp": "2025-04-22T11:20:11Z", + "labels": { + "grafana.app/deprecatedInternalID": "12345" + } + }, + "spec": { + "title": "Hello testdata", + "access": "proxy", + "isDefault": true, + "readOnly": true, + "url": "http://something/", + "database": "db", + "basicAuth": true, + "basicAuthUser": "xxx", + "withCredentials": true, + "jsonData": { + "aaa": "bbb", + "bbb": true, + "ccc": 1.234 + } + }, + "secure": { + "password": { "create": "XXXX" }, + "extra": { "remove": true } + } +} \ No newline at end of file diff --git a/pkg/registry/apis/datasource/testdata/convert-resource-invalid.json b/pkg/registry/apis/datasource/testdata/convert-resource-invalid.json new file mode 100644 index 00000000000..ccd111b6f69 --- /dev/null +++ b/pkg/registry/apis/datasource/testdata/convert-resource-invalid.json @@ -0,0 +1,17 @@ +{ + "apiVersion": "something/else", + "metadata": { + "name": "cejobd88i85j4d", + "namespace": "default", + "uid": "IGIUtEQS21DtLpBG2rSGfuDoUX8cwsGrtb5aXauYeA4X", + "resourceVersion": "1745320815000", + "generation": 2, + "creationTimestamp": "2025-04-22T11:20:11Z", + "labels": { + "grafana.app/deprecatedInternalID": "12345" + } + }, + "spec": { + "title": "Hello testdata" + } +} \ No newline at end of file diff --git a/pkg/registry/apis/datasource/testdata/convert-resource-invalid2.json b/pkg/registry/apis/datasource/testdata/convert-resource-invalid2.json new file mode 100644 index 00000000000..b3f17071243 --- /dev/null +++ b/pkg/registry/apis/datasource/testdata/convert-resource-invalid2.json @@ -0,0 +1,9 @@ +{ + "metadata": { + "name": "cejobd88i85j4d", + "namespace": "stacks-invalid" + }, + "spec": { + "title": "Hello testdata" + } +} \ No newline at end of file diff --git a/pkg/registry/apis/query/connections.go b/pkg/registry/apis/query/connections.go new file mode 100644 index 00000000000..a4a7e385dfc --- /dev/null +++ b/pkg/registry/apis/query/connections.go @@ -0,0 +1,161 @@ +package query + +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/endpoints/request" + "k8s.io/apiserver/pkg/registry/rest" + + authlib "github.com/grafana/authlib/types" + "github.com/grafana/grafana/pkg/apimachinery/utils" + queryV0 "github.com/grafana/grafana/pkg/apis/query/v0alpha1" + gapiutil "github.com/grafana/grafana/pkg/services/apiserver/utils" + "github.com/grafana/grafana/pkg/services/datasources" +) + +var ( + _ rest.Scoper = (*connectionAccess)(nil) + _ rest.SingularNameProvider = (*connectionAccess)(nil) + _ rest.Getter = (*connectionAccess)(nil) + _ rest.Lister = (*connectionAccess)(nil) + _ rest.Storage = (*connectionAccess)(nil) +) + +// Get all datasource connections -- this will be backed by search or duplicated resource in unified storage +type DataSourceConnectionProvider interface { + // Get gets a specific datasource (that the user in context can see) + // The name is {group}:{name}, see /pkg/apis/query/v0alpha1/connection.go#L34 + GetConnection(ctx context.Context, namespace string, name string) (*queryV0.DataSourceConnection, error) + + // List lists all data sources the user in context can see + ListConnections(ctx context.Context, namespace string) (*queryV0.DataSourceConnectionList, error) +} + +type connectionAccess struct { + tableConverter rest.TableConvertor + connections DataSourceConnectionProvider +} + +func (s *connectionAccess) New() runtime.Object { + return queryV0.ConnectionResourceInfo.NewFunc() +} + +func (s *connectionAccess) Destroy() {} + +func (s *connectionAccess) NamespaceScoped() bool { + return true +} + +func (s *connectionAccess) GetSingularName() string { + return queryV0.ConnectionResourceInfo.GetSingularName() +} + +func (s *connectionAccess) ShortNames() []string { + return queryV0.ConnectionResourceInfo.GetShortNames() +} + +func (s *connectionAccess) NewList() runtime.Object { + return queryV0.ConnectionResourceInfo.NewListFunc() +} + +func (s *connectionAccess) ConvertToTable(ctx context.Context, object runtime.Object, tableOptions runtime.Object) (*metav1.Table, error) { + if s.tableConverter == nil { + s.tableConverter = queryV0.ConnectionResourceInfo.TableConverter() + } + return s.tableConverter.ConvertToTable(ctx, object, tableOptions) +} + +func (s *connectionAccess) Get(ctx context.Context, name string, options *metav1.GetOptions) (runtime.Object, error) { + return s.connections.GetConnection(ctx, request.NamespaceValue(ctx), name) +} + +func (s *connectionAccess) List(ctx context.Context, options *internalversion.ListOptions) (runtime.Object, error) { + return s.connections.ListConnections(ctx, request.NamespaceValue(ctx)) +} + +type connectionsProvider struct { + dsService datasources.DataSourceService + registry queryV0.DataSourceApiServerRegistry +} + +var ( + _ DataSourceConnectionProvider = (*connectionsProvider)(nil) +) + +func (q *connectionsProvider) GetConnection(ctx context.Context, namespace string, name string) (*queryV0.DataSourceConnection, error) { + info, err := authlib.ParseNamespace(namespace) + if err != nil { + return nil, err + } + ds, err := q.dsService.GetDataSource(ctx, &datasources.GetDataSourceQuery{ + UID: name, + OrgID: info.OrgID, + }) + if err != nil { + return nil, err + } + + // TODO... access control? + return q.asConnection(ds, namespace) +} + +func (q *connectionsProvider) ListConnections(ctx context.Context, namespace string) (*queryV0.DataSourceConnectionList, error) { + ns, err := authlib.ParseNamespace(namespace) + if err != nil { + return nil, err + } + + dss, err := q.dsService.GetDataSources(ctx, &datasources.GetDataSourcesQuery{ + OrgID: ns.OrgID, + DataSourceLimit: 10000, + }) + if err != nil { + return nil, err + } + result := &queryV0.DataSourceConnectionList{ + Items: []queryV0.DataSourceConnection{}, + } + for _, ds := range dss { + v, err := q.asConnection(ds, namespace) + if err != nil { + return nil, err + } + result.Items = append(result.Items, *v) + } + return result, nil +} + +func (q *connectionsProvider) asConnection(ds *datasources.DataSource, ns string) (v *queryV0.DataSourceConnection, err error) { + gv, err := q.registry.GetDatasourceGroupVersion(ds.Type) + if err != nil { + return nil, fmt.Errorf("datasource type %q does not map to an apiserver %w", ds.Type, err) + } + + v = &queryV0.DataSourceConnection{ + ObjectMeta: metav1.ObjectMeta{ + Name: queryV0.DataSourceConnectionName(gv.Group, ds.UID), + Namespace: ns, + CreationTimestamp: metav1.NewTime(ds.Created), + ResourceVersion: fmt.Sprintf("%d", ds.Updated.UnixMilli()), + Generation: int64(ds.Version), + }, + Title: ds.Name, + Datasource: queryV0.DataSourceConnectionRef{ + Group: gv.Group, + Version: gv.Version, + Name: ds.UID, + }, + } + v.UID = gapiutil.CalculateClusterWideUID(v) // UID is unique across all groups + if !ds.Updated.IsZero() { + meta, err := utils.MetaAccessor(v) + if err != nil { + meta.SetUpdatedTimestamp(&ds.Updated) + } + } + return v, err +} diff --git a/pkg/registry/apis/query/queryschema/oas_helper.go b/pkg/registry/apis/query/queryschema/oas_helper.go index 628dd56751a..5ac97f2577c 100644 --- a/pkg/registry/apis/query/queryschema/oas_helper.go +++ b/pkg/registry/apis/query/queryschema/oas_helper.go @@ -66,21 +66,8 @@ func AddQueriesToOpenAPI(options OASQueryOptions) error { // Rewrite the query path query := oas.Paths.Paths[root+options.QueryPath] if query != nil && query.Post != nil { - query.Post.Tags = []string{"Query"} - query.Parameters = []*spec3.Parameter{ - { - ParameterProps: spec3.ParameterProps{ - Name: "namespace", - In: "path", - Description: "object name and auth scope, such as for teams and projects", - Example: "default", - Required: true, - Schema: spec.StringProperty().UniqueValues(), - }, - }, - } + query.Post.Tags = []string{"DataSource"} query.Post.Description = options.QueryDescription - query.Post.Parameters = nil // query.Post.RequestBody = &spec3.RequestBody{ RequestBodyProps: spec3.RequestBodyProps{ Content: map[string]*spec3.MediaType{ diff --git a/pkg/registry/apis/query/register.go b/pkg/registry/apis/query/register.go index 28ed79e060a..61a90f2b693 100644 --- a/pkg/registry/apis/query/register.go +++ b/pkg/registry/apis/query/register.go @@ -50,6 +50,7 @@ type QueryAPIBuilder struct { converter *expr.ResultConverter queryTypes *query.QueryTypeDefinitionList legacyDatasourceLookup service.LegacyDataSourceLookup + connections DataSourceConnectionProvider } func NewQueryAPIBuilder( @@ -60,6 +61,7 @@ func NewQueryAPIBuilder( registerer prometheus.Registerer, tracer tracing.Tracer, legacyDatasourceLookup service.LegacyDataSourceLookup, + connections DataSourceConnectionProvider, ) (*QueryAPIBuilder, error) { // Include well typed query definitions var queryTypes *query.QueryTypeDefinitionList @@ -86,6 +88,7 @@ func NewQueryAPIBuilder( tracer: tracer, features: features, queryTypes: queryTypes, + connections: connections, converter: &expr.ResultConverter{ Features: features, Tracer: tracer, @@ -127,6 +130,8 @@ func RegisterAPIService( return authorizer.DecisionAllow, "", nil }) + reg := client.NewDataSourceRegistryFromStore(pluginStore, dataSourcesService) + builder, err := NewQueryAPIBuilder( features, client.NewSingleTenantInstanceProvider(cfg, features, pluginClient, pCtxProvider, accessControl), @@ -135,6 +140,7 @@ func RegisterAPIService( registerer, tracer, legacyDatasourceLookup, + &connectionsProvider{dsService: dataSourcesService, registry: reg}, ) apiregistration.RegisterAPI(builder) return builder, err @@ -148,6 +154,8 @@ func addKnownTypes(scheme *runtime.Scheme, gv schema.GroupVersion) { scheme.AddKnownTypes(gv, &query.DataSourceApiServer{}, &query.DataSourceApiServerList{}, + &query.DataSourceConnection{}, + &query.DataSourceConnectionList{}, &query.QueryDataRequest{}, &query.QueryDataResponse{}, &query.QueryTypeDefinition{}, @@ -170,6 +178,14 @@ func (b *QueryAPIBuilder) UpdateAPIGroupInfo(apiGroupInfo *genericapiserver.APIG storage := map[string]rest.Storage{} + // Get a list of all datasource instances + if b.features.IsEnabledGlobally(featuremgmt.FlagQueryServiceWithConnections) { + // Eventually this would be backed either by search or reconciler pattern + storage[query.ConnectionResourceInfo.StoragePath()] = &connectionAccess{ + connections: b.connections, + } + } + plugins := newPluginsStorage(b.registry) storage[plugins.resourceInfo.StoragePath()] = plugins if !b.features.IsEnabledGlobally(featuremgmt.FlagGrafanaAPIServerWithExperimentalAPIs) { diff --git a/pkg/server/wire_gen.go b/pkg/server/wire_gen.go index 390fa53f780..65a13359a15 100644 --- a/pkg/server/wire_gen.go +++ b/pkg/server/wire_gen.go @@ -732,7 +732,7 @@ func Initialize(ctx context.Context, cfg *setting.Cfg, opts Options, apiOpts api if err != nil { return nil, err } - scopedPluginDatasourceProvider := datasource.ProvideDefaultPluginConfigs(service15, cacheServiceImpl, plugincontextProvider) + scopedPluginDatasourceProvider := datasource.ProvideDefaultPluginConfigs(service15, cacheServiceImpl, plugincontextProvider, cfg) v := builder.ProvideDefaultBuildHandlerChainFuncFromBuilders() aggregatorRunner := aggregatorrunner.ProvideNoopAggregatorConfigurator() playlistAppInstaller, err := playlist.RegisterAppInstaller(playlistService, cfg, featureToggles) @@ -1314,7 +1314,7 @@ func InitializeForTest(ctx context.Context, t sqlutil.ITestDB, testingT interfac if err != nil { return nil, err } - scopedPluginDatasourceProvider := datasource.ProvideDefaultPluginConfigs(service15, cacheServiceImpl, plugincontextProvider) + scopedPluginDatasourceProvider := datasource.ProvideDefaultPluginConfigs(service15, cacheServiceImpl, plugincontextProvider, cfg) v := builder.ProvideDefaultBuildHandlerChainFuncFromBuilders() aggregatorRunner := aggregatorrunner.ProvideNoopAggregatorConfigurator() playlistAppInstaller, err := playlist.RegisterAppInstaller(playlistService, cfg, featureToggles) diff --git a/pkg/services/featuremgmt/registry.go b/pkg/services/featuremgmt/registry.go index 07029af9b92..4b0de2ce8d0 100644 --- a/pkg/services/featuremgmt/registry.go +++ b/pkg/services/featuremgmt/registry.go @@ -499,6 +499,13 @@ var ( Owner: grafanaDatasourcesCoreServicesSquad, RequiresRestart: true, // Adds a route at startup }, + { + Name: "queryServiceWithConnections", + Description: "Adds datasource connections to the query service", + Stage: FeatureStageExperimental, + Owner: grafanaDatasourcesCoreServicesSquad, + RequiresRestart: true, // Adds a route at startup + }, { Name: "queryServiceRewrite", Description: "Rewrite requests targeting /ds/query to the query service", diff --git a/pkg/services/featuremgmt/toggles_gen.csv b/pkg/services/featuremgmt/toggles_gen.csv index fb581e030b4..c1728533830 100644 --- a/pkg/services/featuremgmt/toggles_gen.csv +++ b/pkg/services/featuremgmt/toggles_gen.csv @@ -65,6 +65,7 @@ dashboardSchemaValidationLogging,experimental,@grafana/grafana-app-platform-squa scanRowInvalidDashboardParseFallbackEnabled,experimental,@grafana/search-and-storage,false,false,false datasourceQueryTypes,experimental,@grafana/grafana-app-platform-squad,false,true,false queryService,experimental,@grafana/grafana-datasources-core-services,false,true,false +queryServiceWithConnections,experimental,@grafana/grafana-datasources-core-services,false,true,false queryServiceRewrite,experimental,@grafana/grafana-datasources-core-services,false,true,false queryServiceFromUI,experimental,@grafana/grafana-datasources-core-services,false,false,true queryServiceFromExplore,experimental,@grafana/grafana-datasources-core-services,false,false,true diff --git a/pkg/services/featuremgmt/toggles_gen.go b/pkg/services/featuremgmt/toggles_gen.go index 22383ebb4f9..8be11c087c5 100644 --- a/pkg/services/featuremgmt/toggles_gen.go +++ b/pkg/services/featuremgmt/toggles_gen.go @@ -271,6 +271,10 @@ const ( // Register /apis/query.grafana.app/ -- will eventually replace /api/ds/query FlagQueryService = "queryService" + // FlagQueryServiceWithConnections + // Adds datasource connections to the query service + FlagQueryServiceWithConnections = "queryServiceWithConnections" + // FlagQueryServiceRewrite // Rewrite requests targeting /ds/query to the query service FlagQueryServiceRewrite = "queryServiceRewrite" diff --git a/pkg/services/featuremgmt/toggles_gen.json b/pkg/services/featuremgmt/toggles_gen.json index 8a3c0b60263..9f98a5a0751 100644 --- a/pkg/services/featuremgmt/toggles_gen.json +++ b/pkg/services/featuremgmt/toggles_gen.json @@ -2765,6 +2765,19 @@ "requiresRestart": true } }, + { + "metadata": { + "name": "queryServiceWithConnections", + "resourceVersion": "1756367172351", + "creationTimestamp": "2025-08-28T07:46:12Z" + }, + "spec": { + "description": "Adds datasource connections to the query service", + "stage": "experimental", + "codeowner": "@grafana/grafana-datasources-core-services", + "requiresRestart": true + } + }, { "metadata": { "name": "recordedQueriesMulti", diff --git a/pkg/tests/apis/datasource/testdata/testdata-create.yaml b/pkg/tests/apis/datasource/testdata/testdata-create.yaml new file mode 100644 index 00000000000..9bffe6f75ba --- /dev/null +++ b/pkg/tests/apis/datasource/testdata/testdata-create.yaml @@ -0,0 +1,17 @@ +apiVersion: testdata.datasource.grafana.app/v0alpha1 +kind: DataSource +metadata: + name: sample-testdata +spec: + title: Sample datasource + access: proxy + isDefault: true + jsonData: + key: value + hello: 10 + world: false +secure: + sampleA: + create: secret value here # replaced with UID on write + sampleB: + name: XYZ # reference to a existing secret \ No newline at end of file diff --git a/pkg/tests/apis/datasource/testdata_test.go b/pkg/tests/apis/datasource/testdata_test.go index 3ed3b620c16..f49807486e4 100644 --- a/pkg/tests/apis/datasource/testdata_test.go +++ b/pkg/tests/apis/datasource/testdata_test.go @@ -2,13 +2,16 @@ package dashboards import ( "context" + "encoding/json" "fmt" "testing" "github.com/stretchr/testify/require" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/runtime/schema" + "github.com/grafana/grafana/pkg/components/simplejson" "github.com/grafana/grafana/pkg/services/datasources" "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/tests/apis" @@ -38,97 +41,58 @@ func TestIntegrationTestDatasource(t *testing.T) { Type: datasources.DS_TESTDATA, UID: "test", OrgID: int64(1), + + // These settings are not actually used, but testing that they get saved + Database: "testdb", + URL: "http://fake.url", + Access: datasources.DS_ACCESS_PROXY, + User: "example", + ReadOnly: true, + JsonData: simplejson.NewFromAny(map[string]any{ + "hello": "world", + }), + SecureJsonData: map[string]string{ + "aaa": "AAA", + "bbb": "BBB", + }, }) require.Equal(t, "test", ds.UID) - t.Run("Check discovery client", func(t *testing.T) { - disco := helper.GetGroupVersionInfoJSON("testdata.datasource.grafana.app") - fmt.Printf("%s", disco) + t.Run("Admin configs", func(t *testing.T) { + client := helper.Org1.Admin.ResourceClient(t, schema.GroupVersionResource{ + Group: "testdata.datasource.grafana.app", + Version: "v0alpha1", + Resource: "datasources", + }).Namespace("default") + ctx := context.Background() - require.JSONEq(t, `[ - { - "freshness": "Current", - "resources": [ - { - "resource": "connections", - "responseKind": { - "group": "", - "kind": "DataSourceConnection", - "version": "" - }, - "scope": "Namespaced", - "shortNames": [ - "grafana-testdata-datasource-connection" - ], - "singularResource": "connection", - "subresources": [ - { - "responseKind": { - "group": "", - "kind": "HealthCheckResult", - "version": "" - }, - "subresource": "health", - "verbs": [ - "get" - ] - }, - { - "responseKind": { - "group": "", - "kind": "QueryDataResponse", - "version": "" - }, - "subresource": "query", - "verbs": [ - "create" - ] - }, - { - "responseKind": { - "group": "", - "kind": "Status", - "version": "" - }, - "subresource": "resource", - "verbs": [ - "create", - "delete", - "get", - "patch", - "update" - ] - } - ], - "verbs": [ - "get", - "list" - ] - }, - { - "resource": "queryconvert", - "responseKind": { - "group": "", - "kind": "QueryDataRequest", - "version": "" - }, - "scope": "Namespaced", - "singularResource": "queryconvert", - "verbs": [ - "create" - ] - } - ], - "version": "v0alpha1" - } - ]`, disco) + 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") + + 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, + "jsonData": { + "hello": "world" + }, + "readOnly": true, + "title": "test", + "url": "http://fake.url", + "user": "example" + }`, string(jj)) }) t.Run("Call subresources", func(t *testing.T) { client := helper.Org1.Admin.ResourceClient(t, schema.GroupVersionResource{ Group: "testdata.datasource.grafana.app", Version: "v0alpha1", - Resource: "connections", + Resource: "datasources", }).Namespace("default") ctx := context.Background() @@ -155,7 +119,7 @@ func TestIntegrationTestDatasource(t *testing.T) { raw := apis.DoRequest[any](helper, apis.RequestParams{ User: helper.Org1.Admin, Method: "GET", - Path: "/apis/testdata.datasource.grafana.app/v0alpha1/namespaces/default/connections/test/resource", + Path: "/apis/testdata.datasource.grafana.app/v0alpha1/namespaces/default/datasources/test/resource", }, nil) require.Equal(t, `Hello world from test datasource!`, string(raw.Body)) })