diff --git a/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md b/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md index 17ebb0d39ea..21fd4dffe8e 100644 --- a/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md +++ b/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md @@ -128,6 +128,7 @@ Experimental features might be changed or removed without prior notice. | `traceQLStreaming` | Enables response streaming of TraceQL queries of the Tempo data source | | `metricsSummary` | Enables metrics summary queries in the Tempo data source | | `grafanaAPIServer` | Enable Kubernetes API Server for Grafana resources | +| `grafanaAPIServerWithExperimentalAPIs` | Register experimental APIs with the k8s API server | | `featureToggleAdminPage` | Enable admin page for managing feature toggles from the Grafana front-end | | `permissionsFilterRemoveSubquery` | Alternative permission filter implementation that does not use subqueries for fetching the dashboard folder | | `influxdbSqlSupport` | Enable InfluxDB SQL query language support with new querying UI | diff --git a/go.mod b/go.mod index 20a2e3974f3..6132ce63b1c 100644 --- a/go.mod +++ b/go.mod @@ -25,6 +25,10 @@ replace go.opentelemetry.io/otel/metric => go.opentelemetry.io/otel/metric v1.16 // import that instead of v0.X even though v0.X is newer. replace github.com/prometheus/prometheus => github.com/prometheus/prometheus v0.43.0 +// Includes https://github.com/kubernetes/kube-openapi/pull/420 +// This will not be required in the next k8s release +replace k8s.io/kube-openapi v0.0.0-20230501164219-8b0f38b5fd1f => github.com/ryantxu/kube-openapi v0.0.0-20230824154605-fe0f3703fd8d // @grafana/grafana-app-platform-squad + require ( cloud.google.com/go/storage v1.30.1 // @grafana/backend-platform cuelang.org/go v0.6.0-0.dev // @grafana/grafana-as-code @@ -121,7 +125,7 @@ require ( gopkg.in/mail.v2 v2.3.1 // @grafana/backend-platform gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v3 v3.0.1 // @grafana/alerting-squad-backend - xorm.io/builder v0.3.6 // indirect; @grafana/backend-platform + xorm.io/builder v0.3.6 // @grafana/backend-platform xorm.io/core v0.7.3 // @grafana/backend-platform xorm.io/xorm v0.8.2 // @grafana/alerting-squad-backend ) @@ -168,12 +172,12 @@ require ( github.com/google/btree v1.1.2 // indirect github.com/google/flatbuffers v23.1.21+incompatible // indirect github.com/googleapis/gax-go/v2 v2.11.0 // @grafana/backend-platform - github.com/gorilla/mux v1.8.0 // indirect + github.com/gorilla/mux v1.8.0 // @grafana/backend-platform github.com/grafana/grafana-google-sdk-go v0.1.0 // @grafana/partner-datasources github.com/grpc-ecosystem/go-grpc-prometheus v1.2.1-0.20191002090509-6af20e3a5340 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect github.com/hashicorp/go-msgpack v0.5.5 // indirect - github.com/hashicorp/go-multierror v1.1.1 // indirect; @grafana/grafana-as-code + github.com/hashicorp/go-multierror v1.1.1 // @grafana/grafana-as-code github.com/hashicorp/go-sockaddr v1.0.2 // indirect github.com/hashicorp/golang-lru v0.6.0 // indirect github.com/hashicorp/yamux v0.1.1 // indirect diff --git a/go.sum b/go.sum index e3fb570ef47..43db7678d1c 100644 --- a/go.sum +++ b/go.sum @@ -2650,6 +2650,8 @@ github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/ruudk/golang-pdf417 v0.0.0-20181029194003-1af4ab5afa58/go.mod h1:6lfFZQK844Gfx8o5WFuvpxWRwnSoipWe/p622j1v06w= github.com/ruudk/golang-pdf417 v0.0.0-20201230142125-a7e3863a1245/go.mod h1:pQAZKsJ8yyVxGRWYNEm9oFB8ieLgKFnamEyDmSA0BRk= +github.com/ryantxu/kube-openapi v0.0.0-20230824154605-fe0f3703fd8d h1:/aSzsDXklM83yExguLXwl/nMrW8/7Lgefg2q632YynY= +github.com/ryantxu/kube-openapi v0.0.0-20230824154605-fe0f3703fd8d/go.mod h1:byini6yhqGC14c3ebc/QwanvYwhuMWF6yz2F8uwW8eg= github.com/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts= github.com/ryanuber/columnize v2.1.0+incompatible/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts= github.com/sagikazarmark/crypt v0.6.0/go.mod h1:U8+INwJo3nBv1m6A/8OBXAq7Jnpspk5AxSgDyEQcea8= @@ -4084,8 +4086,6 @@ k8s.io/kms v0.27.1 h1:JTSQbJb+mcobScQwF0bOmZhIwP17k8GvBsiLlA6SQqw= k8s.io/kms v0.27.1/go.mod h1:VuTsw0uHlSycKLCkypCGxfFCjLfzf/5YMeATECd/zJA= k8s.io/kube-openapi v0.0.0-20221012153701-172d655c2280/go.mod h1:+Axhij7bCpeqhklhUTe3xmOn6bWxolyZEeyaFpjGtl4= k8s.io/kube-openapi v0.0.0-20230303024457-afdc3dddf62d/go.mod h1:y5VtZWM9sHHc2ZodIH/6SHzXj+TPU5USoA8lcIeKEKY= -k8s.io/kube-openapi v0.0.0-20230501164219-8b0f38b5fd1f h1:2kWPakN3i/k81b0gvD5C5FJ2kxm1WrQFanWchyKuqGg= -k8s.io/kube-openapi v0.0.0-20230501164219-8b0f38b5fd1f/go.mod h1:byini6yhqGC14c3ebc/QwanvYwhuMWF6yz2F8uwW8eg= k8s.io/utils v0.0.0-20210802155522-efc7438f0176/go.mod h1:jPW/WVKK9YHAvNhRxK0md/EJ228hCsBRufyofKtW8HA= k8s.io/utils v0.0.0-20221107191617-1a15be271d1d/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= k8s.io/utils v0.0.0-20230308161112-d77c459e9343/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= diff --git a/packages/grafana-data/src/types/featureToggles.gen.ts b/packages/grafana-data/src/types/featureToggles.gen.ts index d779bb753f3..a879ded6895 100644 --- a/packages/grafana-data/src/types/featureToggles.gen.ts +++ b/packages/grafana-data/src/types/featureToggles.gen.ts @@ -109,6 +109,7 @@ export interface FeatureToggles { traceQLStreaming?: boolean; metricsSummary?: boolean; grafanaAPIServer?: boolean; + grafanaAPIServerWithExperimentalAPIs?: boolean; featureToggleAdminPage?: boolean; awsAsyncQueryCaching?: boolean; splitScopes?: boolean; diff --git a/pkg/apis/example/v0alpha1/doc.go b/pkg/apis/example/v0alpha1/doc.go new file mode 100644 index 00000000000..84d861cb5b7 --- /dev/null +++ b/pkg/apis/example/v0alpha1/doc.go @@ -0,0 +1,10 @@ +// +k8s:deepcopy-gen=package +// +k8s:openapi-gen=true +// +groupName=example.grafana.com + +// The testing api is a dependency free service that we can use to experiment with +// api aggregation across multiple deployment models. Specifically: +// - standalone: running as part of the standard grafana build +// - aggregated: running as the target + +package v0alpha1 // import "github.com/grafana/grafana/pkg/apis/example/v0alpha1" diff --git a/pkg/apis/example/v0alpha1/openapi.go b/pkg/apis/example/v0alpha1/openapi.go new file mode 100644 index 00000000000..7d10f829ae0 --- /dev/null +++ b/pkg/apis/example/v0alpha1/openapi.go @@ -0,0 +1,89 @@ +package v0alpha1 + +import ( + common "k8s.io/kube-openapi/pkg/common" + spec "k8s.io/kube-openapi/pkg/validation/spec" +) + +// NOTE: this must match the golang fully qualified name! +const kindKey = "github.com/grafana/grafana/pkg/apis/example/v0alpha1.RuntimeInfo" + +func getOpenAPIDefinitions(ref common.ReferenceCallback) map[string]common.OpenAPIDefinition { + return map[string]common.OpenAPIDefinition{ + kindKey: schema_pkg_apis_example_v0alpha1_RuntimeInfo(ref), + } +} + +func schema_pkg_apis_example_v0alpha1_RuntimeInfo(ref common.ReferenceCallback) common.OpenAPIDefinition { + return common.OpenAPIDefinition{ + Schema: spec.Schema{ + SchemaProps: spec.SchemaProps{ + Type: []string{"object"}, + Properties: map[string]spec.Schema{ + "kind": { + SchemaProps: spec.SchemaProps{ + Description: "Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds", + Type: []string{"string"}, + Format: "", + }, + }, + "apiVersion": { + SchemaProps: spec.SchemaProps{ + Description: "APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources", + Type: []string{"string"}, + Format: "", + }, + }, + "startupTime": { + SchemaProps: spec.SchemaProps{ + Type: []string{"integer"}, + Format: "int64", + }, + }, + "buildVersion": { + SchemaProps: spec.SchemaProps{ + Type: []string{"string"}, + Format: "", + }, + }, + "buildCommit": { + SchemaProps: spec.SchemaProps{ + Type: []string{"string"}, + Format: "", + }, + }, + "enterpriseBuildCommit": { + SchemaProps: spec.SchemaProps{ + Type: []string{"string"}, + Format: "", + }, + }, + "buildBranch": { + SchemaProps: spec.SchemaProps{ + Type: []string{"string"}, + Format: "", + }, + }, + "buildStamp": { + SchemaProps: spec.SchemaProps{ + Type: []string{"integer"}, + Format: "int64", + }, + }, + "enterprise": { + SchemaProps: spec.SchemaProps{ + Type: []string{"boolean"}, + Format: "", + }, + }, + "packaging": { + SchemaProps: spec.SchemaProps{ + Type: []string{"string"}, + Format: "", + }, + }, + }, + }, + }, + } +} diff --git a/pkg/apis/example/v0alpha1/register.go b/pkg/apis/example/v0alpha1/register.go new file mode 100644 index 00000000000..88122ef0ccd --- /dev/null +++ b/pkg/apis/example/v0alpha1/register.go @@ -0,0 +1,195 @@ +package v0alpha1 + +import ( + "fmt" + "net/http" + + "github.com/grafana/grafana/pkg/services/featuremgmt" + grafanaapiserver "github.com/grafana/grafana/pkg/services/grafana-apiserver" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/runtime/serializer" + "k8s.io/apiserver/pkg/endpoints/handlers/responsewriters" + "k8s.io/apiserver/pkg/endpoints/request" + "k8s.io/apiserver/pkg/registry/generic" + "k8s.io/apiserver/pkg/registry/rest" + genericapiserver "k8s.io/apiserver/pkg/server" + common "k8s.io/kube-openapi/pkg/common" + "k8s.io/kube-openapi/pkg/spec3" + "k8s.io/kube-openapi/pkg/validation/spec" +) + +// GroupName is the group name for this API. +const GroupName = "example.grafana.com" +const VersionID = "v0alpha1" // +const APIVersion = GroupName + "/" + VersionID + +var _ grafanaapiserver.APIGroupBuilder = (*TestingAPIBuilder)(nil) + +// This is used just so wire has something unique to return +type TestingAPIBuilder struct { + codecs serializer.CodecFactory +} + +func RegisterAPIService(features featuremgmt.FeatureToggles, apiregistration grafanaapiserver.APIRegistrar) *TestingAPIBuilder { + if !features.IsEnabled(featuremgmt.FlagGrafanaAPIServerWithExperimentalAPIs) { + return nil // skip registration unless opting into experimental apis + } + builder := &TestingAPIBuilder{} + apiregistration.RegisterAPI(builder) + return builder +} + +func (b *TestingAPIBuilder) GetGroupVersion() schema.GroupVersion { + return SchemeGroupVersion +} + +func (b *TestingAPIBuilder) InstallSchema(scheme *runtime.Scheme) error { + err := AddToScheme(scheme) + if err != nil { + return err + } + return scheme.SetVersionPriority(SchemeGroupVersion) +} + +func (b *TestingAPIBuilder) GetAPIGroupInfo( + scheme *runtime.Scheme, + codecs serializer.CodecFactory, // pointer? + optsGetter generic.RESTOptionsGetter, +) (*genericapiserver.APIGroupInfo, error) { + apiGroupInfo := genericapiserver.NewDefaultAPIGroupInfo(GroupName, scheme, metav1.ParameterCodec, codecs) + storage := map[string]rest.Storage{} + storage["runtime"] = newDeploymentInfoStorage() + apiGroupInfo.VersionedResourcesStorageMap[VersionID] = storage + b.codecs = codecs + return &apiGroupInfo, nil +} + +func (b *TestingAPIBuilder) GetOpenAPIDefinitions() common.GetOpenAPIDefinitions { + return getOpenAPIDefinitions +} + +// Register additional routes with the server +func (b *TestingAPIBuilder) GetAPIRoutes() *grafanaapiserver.APIRoutes { + return &grafanaapiserver.APIRoutes{ + Root: []grafanaapiserver.APIRouteHandler{ + { + Path: "/aaa", + Spec: &spec3.PathProps{ + Summary: "an example at the root level", + Description: "longer description here?", + Get: &spec3.Operation{ + OperationProps: spec3.OperationProps{ + Parameters: []*spec3.Parameter{ + {ParameterProps: spec3.ParameterProps{ + Name: "a", + }}, + }, + Responses: &spec3.Responses{ + ResponsesProps: spec3.ResponsesProps{ + StatusCodeResponses: map[int]*spec3.Response{ + 200: { + ResponseProps: spec3.ResponseProps{ + Description: "OK", + Content: map[string]*spec3.MediaType{ + "text/plain": { + MediaTypeProps: spec3.MediaTypeProps{ + Schema: &spec.Schema{ + SchemaProps: spec.SchemaProps{ + Type: []string{"string"}, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + Handler: func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte("Root level handler (aaa)")) + }, + }, + { + Path: "/bbb", + Spec: &spec3.PathProps{ + Summary: "an example at the root level", + Description: "longer description here?", + Get: &spec3.Operation{ + OperationProps: spec3.OperationProps{ + Parameters: []*spec3.Parameter{ + {ParameterProps: spec3.ParameterProps{ + Name: "b", + }}, + }, + }, + }, + }, + Handler: func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte("Root level handler (bbb)")) + }, + }, + }, + Namespace: []grafanaapiserver.APIRouteHandler{ + { + Path: "/ccc", + Spec: &spec3.PathProps{ + Summary: "an example at the root level", + Description: "longer description here?", + Get: &spec3.Operation{ + OperationProps: spec3.OperationProps{ + Parameters: []*spec3.Parameter{ + {ParameterProps: spec3.ParameterProps{ + Name: "a", + }}, + }, + }, + }, + }, + Handler: func(w http.ResponseWriter, r *http.Request) { + info, ok := request.RequestInfoFrom(r.Context()) + if !ok { + responsewriters.ErrorNegotiated( + apierrors.NewInternalError(fmt.Errorf("no RequestInfo found in the context")), + b.codecs, schema.GroupVersion{}, w, r, + ) + return + } + + _, _ = w.Write([]byte("Custom namespace route ccc: " + info.Namespace)) + }, + }, + }, + } +} + +// SchemeGroupVersion is group version used to register these objects +var SchemeGroupVersion = schema.GroupVersion{Group: GroupName, Version: VersionID} + +// Resource takes an unqualified resource and returns a Group qualified GroupResource +func Resource(resource string) schema.GroupResource { + return SchemeGroupVersion.WithResource(resource).GroupResource() +} + +var ( + // SchemeBuilder points to a list of functions added to Scheme. + SchemeBuilder = runtime.NewSchemeBuilder(addKnownTypes) + localSchemeBuilder = &SchemeBuilder + // AddToScheme is a common registration function for mapping packaged scoped group & version keys to a scheme. + AddToScheme = localSchemeBuilder.AddToScheme +) + +// Adds the list of known types to the given scheme. +func addKnownTypes(scheme *runtime.Scheme) error { + scheme.AddKnownTypes(SchemeGroupVersion, + &RuntimeInfo{}, + ) + metav1.AddToGroupVersion(scheme, SchemeGroupVersion) + return nil +} diff --git a/pkg/apis/example/v0alpha1/storage.go b/pkg/apis/example/v0alpha1/storage.go new file mode 100644 index 00000000000..33322f9581b --- /dev/null +++ b/pkg/apis/example/v0alpha1/storage.go @@ -0,0 +1,69 @@ +package v0alpha1 + +import ( + "context" + "time" + + "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/setting" +) + +var ( + _ rest.Scoper = (*staticStorage)(nil) + _ rest.SingularNameProvider = (*staticStorage)(nil) + _ rest.Lister = (*staticStorage)(nil) + _ rest.Storage = (*staticStorage)(nil) +) + +type staticStorage struct { + info RuntimeInfo +} + +func newDeploymentInfoStorage() *staticStorage { + return &staticStorage{ + info: RuntimeInfo{ + TypeMeta: metav1.TypeMeta{ + APIVersion: APIVersion, + Kind: "DeploymentInfo", + }, + BuildVersion: setting.BuildVersion, + BuildCommit: setting.BuildCommit, + BuildBranch: setting.BuildBranch, + EnterpriseBuildCommit: setting.EnterpriseBuildCommit, + BuildStamp: setting.BuildStamp, + IsEnterprise: setting.IsEnterprise, + Packaging: setting.Packaging, + StartupTime: time.Now().UnixMilli(), + }, + } +} + +func (s *staticStorage) New() runtime.Object { + return &RuntimeInfo{} +} + +func (s *staticStorage) Destroy() {} + +func (s *staticStorage) NamespaceScoped() bool { + return false +} + +func (s *staticStorage) GetSingularName() string { + return "runtime" +} + +func (s *staticStorage) NewList() runtime.Object { + return &RuntimeInfo{} +} + +func (s *staticStorage) ConvertToTable(ctx context.Context, object runtime.Object, tableOptions runtime.Object) (*metav1.Table, error) { + return rest.NewDefaultTableConvertor(Resource("runtime")).ConvertToTable(ctx, object, tableOptions) +} + +func (s *staticStorage) List(ctx context.Context, options *internalversion.ListOptions) (runtime.Object, error) { + return &s.info, nil +} diff --git a/pkg/apis/example/v0alpha1/types.go b/pkg/apis/example/v0alpha1/types.go new file mode 100644 index 00000000000..61ba3e89474 --- /dev/null +++ b/pkg/apis/example/v0alpha1/types.go @@ -0,0 +1,20 @@ +package v0alpha1 + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object +type RuntimeInfo struct { + metav1.TypeMeta `json:",inline"` + + StartupTime int64 `json:"startupTime,omitempty"` + + BuildVersion string `json:"buildVersion,omitempty"` + BuildCommit string `json:"buildCommit,omitempty"` + EnterpriseBuildCommit string `json:"enterpriseBuildCommit,omitempty"` + BuildBranch string `json:"buildBranch,omitempty"` + BuildStamp int64 `json:"buildStamp,omitempty"` + IsEnterprise bool `json:"enterprise,omitempty"` + Packaging string `json:"packaging,omitempty"` +} diff --git a/pkg/apis/example/v0alpha1/zz_generated.deepcopy.go b/pkg/apis/example/v0alpha1/zz_generated.deepcopy.go new file mode 100644 index 00000000000..487bf8de819 --- /dev/null +++ b/pkg/apis/example/v0alpha1/zz_generated.deepcopy.go @@ -0,0 +1,37 @@ +//go:build !ignore_autogenerated +// +build !ignore_autogenerated + +// SPDX-License-Identifier: AGPL-3.0-only + +// Code generated by deepcopy-gen. DO NOT EDIT. + +package v0alpha1 + +import ( + 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 *RuntimeInfo) DeepCopyInto(out *RuntimeInfo) { + *out = *in + out.TypeMeta = in.TypeMeta + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RuntimeInfo. +func (in *RuntimeInfo) DeepCopy() *RuntimeInfo { + if in == nil { + return nil + } + out := new(RuntimeInfo) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *RuntimeInfo) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} diff --git a/pkg/apis/playlist/v0alpha1/register.go b/pkg/apis/playlist/v0alpha1/register.go index 9eea9f0b67a..1c0e59edd4b 100644 --- a/pkg/apis/playlist/v0alpha1/register.go +++ b/pkg/apis/playlist/v0alpha1/register.go @@ -9,7 +9,6 @@ import ( "k8s.io/apiserver/pkg/registry/rest" genericapiserver "k8s.io/apiserver/pkg/server" common "k8s.io/kube-openapi/pkg/common" - "k8s.io/kube-openapi/pkg/spec3" grafanaapiserver "github.com/grafana/grafana/pkg/services/grafana-apiserver" grafanarest "github.com/grafana/grafana/pkg/services/grafana-apiserver/rest" @@ -36,6 +35,10 @@ func RegisterAPIService(p playlist.Service, apiregistration grafanaapiserver.API return builder } +func (b *PlaylistAPIBuilder) GetGroupVersion() schema.GroupVersion { + return SchemeGroupVersion +} + func (b *PlaylistAPIBuilder) InstallSchema(scheme *runtime.Scheme) error { err := AddToScheme(scheme) if err != nil { @@ -72,9 +75,8 @@ func (b *PlaylistAPIBuilder) GetOpenAPIDefinitions() common.GetOpenAPIDefinition return getOpenAPIDefinitions } -// Register additional routes with the server -func (b *PlaylistAPIBuilder) GetOpenAPIPostProcessor() func(*spec3.OpenAPI) (*spec3.OpenAPI, error) { - return nil +func (b *PlaylistAPIBuilder) GetAPIRoutes() *grafanaapiserver.APIRoutes { + return nil // no custom API routes } // SchemeGroupVersion is group version used to register these objects diff --git a/pkg/apis/wireset.go b/pkg/apis/wireset.go index 3b7e32a029e..30b3cbe489c 100644 --- a/pkg/apis/wireset.go +++ b/pkg/apis/wireset.go @@ -3,9 +3,13 @@ package apis import ( "github.com/google/wire" + examplev0alpha1 "github.com/grafana/grafana/pkg/apis/example/v0alpha1" playlistsv0alpha1 "github.com/grafana/grafana/pkg/apis/playlist/v0alpha1" ) +// WireSet is the list of all services +// NOTE: you must also register the service in: pkg/registry/apis/apis.go var WireSet = wire.NewSet( playlistsv0alpha1.RegisterAPIService, + examplev0alpha1.RegisterAPIService, ) diff --git a/pkg/registry/apis/apis.go b/pkg/registry/apis/apis.go index 8476523220d..546b75f5fa8 100644 --- a/pkg/registry/apis/apis.go +++ b/pkg/registry/apis/apis.go @@ -3,6 +3,7 @@ package apiregistry import ( "context" + examplev0alpha1 "github.com/grafana/grafana/pkg/apis/example/v0alpha1" playlistsv0alpha1 "github.com/grafana/grafana/pkg/apis/playlist/v0alpha1" "github.com/grafana/grafana/pkg/registry" ) @@ -13,8 +14,11 @@ var ( type Service struct{} +// ProvideService is an entry point for each service that will force initialization +// and give each builder the chance to register itself with the main server func ProvideService( _ *playlistsv0alpha1.PlaylistAPIBuilder, + _ *examplev0alpha1.TestingAPIBuilder, ) *Service { return &Service{} } diff --git a/pkg/services/featuremgmt/registry.go b/pkg/services/featuremgmt/registry.go index eac13ab729b..f390304cf37 100644 --- a/pkg/services/featuremgmt/registry.go +++ b/pkg/services/featuremgmt/registry.go @@ -629,6 +629,13 @@ var ( FrontendOnly: false, Owner: grafanaAppPlatformSquad, }, + { + Name: "grafanaAPIServerWithExperimentalAPIs", + Description: "Register experimental APIs with the k8s API server", + Stage: FeatureStageExperimental, + FrontendOnly: false, + Owner: grafanaAppPlatformSquad, + }, { Name: "featureToggleAdminPage", Description: "Enable admin page for managing feature toggles from the Grafana front-end", diff --git a/pkg/services/featuremgmt/toggles_gen.csv b/pkg/services/featuremgmt/toggles_gen.csv index 0f02d3fb772..11418b803b4 100644 --- a/pkg/services/featuremgmt/toggles_gen.csv +++ b/pkg/services/featuremgmt/toggles_gen.csv @@ -90,6 +90,7 @@ mlExpressions,experimental,@grafana/alerting-squad,false,false,false,false traceQLStreaming,experimental,@grafana/observability-traces-and-profiling,false,false,false,true metricsSummary,experimental,@grafana/observability-traces-and-profiling,false,false,false,true grafanaAPIServer,experimental,@grafana/grafana-app-platform-squad,false,false,false,false +grafanaAPIServerWithExperimentalAPIs,experimental,@grafana/grafana-app-platform-squad,false,false,false,false featureToggleAdminPage,experimental,@grafana/grafana-operator-experience-squad,false,false,true,false awsAsyncQueryCaching,preview,@grafana/aws-datasources,false,false,false,false splitScopes,preview,@grafana/grafana-authnz-team,false,false,true,false diff --git a/pkg/services/featuremgmt/toggles_gen.go b/pkg/services/featuremgmt/toggles_gen.go index f8db90709e8..be4be3adb42 100644 --- a/pkg/services/featuremgmt/toggles_gen.go +++ b/pkg/services/featuremgmt/toggles_gen.go @@ -371,6 +371,10 @@ const ( // Enable Kubernetes API Server for Grafana resources FlagGrafanaAPIServer = "grafanaAPIServer" + // FlagGrafanaAPIServerWithExperimentalAPIs + // Register experimental APIs with the k8s API server + FlagGrafanaAPIServerWithExperimentalAPIs = "grafanaAPIServerWithExperimentalAPIs" + // FlagFeatureToggleAdminPage // Enable admin page for managing feature toggles from the Grafana front-end FlagFeatureToggleAdminPage = "featureToggleAdminPage" diff --git a/pkg/services/grafana-apiserver/common.go b/pkg/services/grafana-apiserver/common.go index 1c14c299868..94aedc0cd7b 100644 --- a/pkg/services/grafana-apiserver/common.go +++ b/pkg/services/grafana-apiserver/common.go @@ -1,7 +1,10 @@ package grafanaapiserver import ( + "net/http" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" "k8s.io/apimachinery/pkg/runtime/serializer" "k8s.io/apiserver/pkg/registry/generic" genericapiserver "k8s.io/apiserver/pkg/server" @@ -12,6 +15,9 @@ import ( // TODO: this (or something like it) belongs in grafana-app-sdk, // but lets keep it here while we iterate on a few simple examples type APIGroupBuilder interface { + // Get the main group name + GetGroupVersion() schema.GroupVersion + // Add the kinds to the server scheme InstallSchema(scheme *runtime.Scheme) error @@ -25,6 +31,27 @@ type APIGroupBuilder interface { // Get OpenAPI definitions GetOpenAPIDefinitions() common.GetOpenAPIDefinitions - // Register additional routes with the server - GetOpenAPIPostProcessor() func(*spec3.OpenAPI) (*spec3.OpenAPI, error) + // Get the API routes for each version + GetAPIRoutes() *APIRoutes +} + +// This is used to implement dynamic sub-resources like pods/x/logs +type APIRouteHandler struct { + Path string // added to the appropriate level + Spec *spec3.PathProps // Exposed in the open api service discovery + Handler http.HandlerFunc // when Level = resource, the resource will be available in context +} + +// APIRoutes define the +type APIRoutes struct { + // Root handlers are registered directly after the apiVersion identifier + Root []APIRouteHandler + + // Namespace handlers are mounted under the namespace + Namespace []APIRouteHandler + + // Resource routes behave the same as pod/logs + // it looks like a sub-resource, however the response is backed directly by an http handler + // The current resource can be fetched through context + Resource map[string]APIRouteHandler } diff --git a/pkg/services/grafana-apiserver/request_handler.go b/pkg/services/grafana-apiserver/request_handler.go new file mode 100644 index 00000000000..e9f04ad4bda --- /dev/null +++ b/pkg/services/grafana-apiserver/request_handler.go @@ -0,0 +1,198 @@ +package grafanaapiserver + +import ( + "fmt" + "net/http" + "strings" + + "github.com/gorilla/mux" + restclient "k8s.io/client-go/rest" + "k8s.io/kube-openapi/pkg/spec3" +) + +type requestHandler struct { + router *mux.Router +} + +func getAPIHandler(delegateHandler http.Handler, restConfig *restclient.Config, builders []APIGroupBuilder) (http.Handler, error) { + useful := false // only true if any routes exist anywhere + router := mux.NewRouter() + var err error + + for _, builder := range builders { + routes := builder.GetAPIRoutes() + if routes == nil { + continue + } + + gv := builder.GetGroupVersion() + prefix := "/apis/" + gv.String() + + // Root handlers + var sub *mux.Router + for _, route := range routes.Root { + err = validPath(route.Path) + if err != nil { + return nil, err + } + + if sub == nil { + sub = router.PathPrefix(prefix).Subrouter() + sub.MethodNotAllowedHandler = &methodNotAllowedHandler{} + } + + useful = true + methods, err := methodsFromSpec(route.Path, route.Spec) + if err != nil { + return nil, err + } + sub.HandleFunc(route.Path, route.Handler). + Methods(methods...) + } + + // Namespace handlers + sub = nil + prefix += "/namespaces/{namespace}" + for _, route := range routes.Namespace { + err = validPath(route.Path) + if err != nil { + return nil, err + } + if sub == nil { + sub = router.PathPrefix(prefix).Subrouter() + sub.MethodNotAllowedHandler = &methodNotAllowedHandler{} + } + + useful = true + methods, err := methodsFromSpec(route.Path, route.Spec) + if err != nil { + return nil, err + } + sub.HandleFunc(route.Path, route.Handler). + Methods(methods...) + } + + // getter := makeGetter(restConfig) + for resource, route := range routes.Resource { + err = validPath(route.Path) + if err != nil { + return nil, err + } + fmt.Printf("TODO: %s/%v\n", resource, route) + + // get a client for that resource kind + //getter := makeGetter(restConfig) + + useful = true + // sub.HandleFunc(v.Slug, SubresourceHandlerWrapper(v.Handler, getter)). + // Methods(methods...) + } + } + + if !useful { + return delegateHandler, nil + } + + // Per Gorilla Mux issue here: https://github.com/gorilla/mux/issues/616#issuecomment-798807509 + // default handler must come last + router.PathPrefix("/").Handler(delegateHandler) + + return &requestHandler{ + router: router, + }, nil +} + +// The registered path must start with a slash, and (for now) not have any more +func validPath(p string) error { + if !strings.HasPrefix(p, "/") { + return fmt.Errorf("path must start with slash") + } + if strings.Count(p, "/") > 1 { + return fmt.Errorf("path can only have one slash (for now)") + } + return nil +} + +func (h *requestHandler) ServeHTTP(w http.ResponseWriter, req *http.Request) { + h.router.ServeHTTP(w, req) +} + +func methodsFromSpec(slug string, props *spec3.PathProps) ([]string, error) { + if props == nil { + return []string{"GET", "POST", "PUT", "PATCH", "DELETE"}, nil + } + + methods := make([]string, 0) + if props.Get != nil { + methods = append(methods, "GET") + } + if props.Post != nil { + methods = append(methods, "POST") + } + if props.Put != nil { + methods = append(methods, "PUT") + } + if props.Patch != nil { + methods = append(methods, "PATCH") + } + if props.Delete != nil { + methods = append(methods, "DELETE") + } + + if len(methods) == 0 { + return nil, fmt.Errorf("invalid OpenAPI Spec for slug=%s without any methods in PathProps", slug) + } + + return methods, nil +} + +type methodNotAllowedHandler struct{} + +func (h *methodNotAllowedHandler) ServeHTTP(w http.ResponseWriter, req *http.Request) { + w.WriteHeader(405) // method not allowed +} + +// Modify the the OpenAPI spec to include the additional routes. +// Currently this requires: https://github.com/kubernetes/kube-openapi/pull/420 +// In future k8s release, the hook will use Config3 rather than the same hook for both v2 and v3 +func getOpenAPIPostProcessor(builders []APIGroupBuilder) func(*spec3.OpenAPI) (*spec3.OpenAPI, error) { + return func(s *spec3.OpenAPI) (*spec3.OpenAPI, error) { + if s.Paths == nil { + return s, nil + } + for _, builder := range builders { + routes := builder.GetAPIRoutes() + if routes == nil { + continue + } + + gv := builder.GetGroupVersion() + prefix := "/apis/" + gv.String() + if s.Paths.Paths[prefix] != nil { + copy := *s // will copy the rest of the properties + copy.Info.Title = "Grafana API server: " + gv.Group + + for _, route := range routes.Root { + copy.Paths.Paths[prefix+route.Path] = &spec3.Path{ + PathProps: *route.Spec, + } + } + + for _, route := range routes.Namespace { + copy.Paths.Paths[prefix+"/namespaces/{namespace}"+route.Path] = &spec3.Path{ + PathProps: *route.Spec, + } + } + + for resource, route := range routes.Resource { + copy.Paths.Paths[prefix+"/namespaces/{namespace}/"+resource+"{name}"+route.Path] = &spec3.Path{ + PathProps: *route.Spec, + } + } + + return ©, nil + } + } + return s, nil + } +} diff --git a/pkg/services/grafana-apiserver/service.go b/pkg/services/grafana-apiserver/service.go index 8935ed09e33..ea5fe1ad186 100644 --- a/pkg/services/grafana-apiserver/service.go +++ b/pkg/services/grafana-apiserver/service.go @@ -3,7 +3,9 @@ package grafanaapiserver import ( "context" "crypto/x509" + "fmt" "net" + "net/http" "path" "strconv" @@ -248,7 +250,23 @@ func (s *service) start(ctx context.Context) error { openapi.GetOpenAPIDefinitionsWithoutDisabledFeatures(defsGetter), openapinamer.NewDefinitionNamer(Scheme, scheme.Scheme)) + // Add the custom routes to service discovery + serverConfig.OpenAPIV3Config.PostProcessSpec3 = getOpenAPIPostProcessor(builders) + serverConfig.SkipOpenAPIInstallation = false + serverConfig.BuildHandlerChainFunc = func(delegateHandler http.Handler, c *genericapiserver.Config) http.Handler { + // Call DefaultBuildHandlerChain on the main entrypoint http.Handler + // See https://github.com/kubernetes/apiserver/blob/v0.28.0/pkg/server/config.go#L906 + // DefaultBuildHandlerChain provides many things, notably CORS, HSTS, cache-control, authz and latency tracking + requestHandler, err := getAPIHandler( + delegateHandler, + c.LoopbackClientConfig, + builders) + if err != nil { + panic(fmt.Sprintf("could not build handler chain func: %s", err.Error())) + } + return genericapiserver.DefaultBuildHandlerChain(requestHandler, c) + } // Create the server server, err := serverConfig.Complete().New("grafana-apiserver", genericapiserver.NewEmptyDelegate())