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 22cccf9e608..ab2e318bc9b 100644 --- a/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md +++ b/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md @@ -150,7 +150,7 @@ Experimental features might be changed or removed without prior notice. | `panelMonitoring` | Enables panel monitoring through logs and measurements | | `enableNativeHTTPHistogram` | Enables native HTTP Histograms | | `kubernetesPlaylists` | Use the kubernetes API in the frontend for playlists, and route /api/playlist requests to k8s | -| `kubernetesSnapshots` | Use the kubernetes API in the frontend to support playlists | +| `kubernetesSnapshots` | Routes snapshot requests from /api to the /apis endpoint | | `lokiStructuredMetadata` | Enables the loki data source to request structured metadata from the Loki server | | `teamHttpHeaders` | Enables datasources to apply team headers to the client requests | | `cachingOptimizeSerializationMemoryUsage` | If enabled, the caching backend gradually serializes query responses for the cache, comparing against the configured `[caching]max_value_mb` value as it goes. This can can help prevent Grafana from running out of memory while attempting to cache very large query responses. | diff --git a/pkg/api/api.go b/pkg/api/api.go index 1e23f9ee932..fe5bfc191dd 100644 --- a/pkg/api/api.go +++ b/pkg/api/api.go @@ -625,7 +625,7 @@ func (hs *HTTPServer) registerRoutes() { r.Get("/avatar/:hash", requestmeta.SetSLOGroup(requestmeta.SLOGroupHighSlow), hs.AvatarCacheServer.Handler) // Snapshots - r.Post("/api/snapshots/", reqSnapshotPublicModeOrSignedIn, hs.CreateDashboardSnapshot) + r.Post("/api/snapshots/", reqSnapshotPublicModeOrSignedIn, hs.getCreatedSnapshotHandler()) r.Get("/api/snapshot/shared-options/", reqSignedIn, hs.GetSharingOptions) r.Get("/api/snapshots/:key", routing.Wrap(hs.GetDashboardSnapshot)) r.Get("/api/snapshots-delete/:deleteKey", reqSnapshotPublicModeOrSignedIn, routing.Wrap(hs.DeleteDashboardSnapshotByDeleteKey)) diff --git a/pkg/api/dashboard_snapshot.go b/pkg/api/dashboard_snapshot.go index 67f4df9c7a7..b217d854a5f 100644 --- a/pkg/api/dashboard_snapshot.go +++ b/pkg/api/dashboard_snapshot.go @@ -2,21 +2,44 @@ package api import ( "errors" + "fmt" "net/http" "time" "github.com/grafana/grafana/pkg/api/dtos" "github.com/grafana/grafana/pkg/api/response" dashboardsnapshot "github.com/grafana/grafana/pkg/apis/dashboardsnapshot/v0alpha1" + "github.com/grafana/grafana/pkg/infra/appcontext" "github.com/grafana/grafana/pkg/infra/metrics" + "github.com/grafana/grafana/pkg/services/apiserver/endpoints/request" contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" "github.com/grafana/grafana/pkg/services/dashboards" "github.com/grafana/grafana/pkg/services/dashboardsnapshots" + "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/guardian" "github.com/grafana/grafana/pkg/util" + "github.com/grafana/grafana/pkg/util/errutil/errhttp" "github.com/grafana/grafana/pkg/web" ) +// r.Post("/api/snapshots/" +func (hs *HTTPServer) getCreatedSnapshotHandler() web.Handler { + if hs.Features.IsEnabledGlobally(featuremgmt.FlagKubernetesSnapshots) { + namespaceMapper := request.GetNamespaceMapper(hs.Cfg) + return func(w http.ResponseWriter, r *http.Request) { + user, err := appcontext.User(r.Context()) + if err != nil || user == nil { + errhttp.Write(r.Context(), fmt.Errorf("no user"), w) + return + } + r.URL.Path = "/apis/dashboardsnapshot.grafana.app/v0alpha1/namespaces/" + + namespaceMapper(user.OrgID) + "/dashboardsnapshots/create" + hs.clientConfigProvider.DirectlyServeHTTP(w, r) + } + } + return hs.CreateDashboardSnapshot +} + // swagger:route GET /snapshot/shared-options snapshots getSharingOptions // // Get snapshot sharing settings. diff --git a/pkg/registry/apis/dashboardsnapshot/register.go b/pkg/registry/apis/dashboardsnapshot/register.go index e59601696f3..98a42c684bb 100644 --- a/pkg/registry/apis/dashboardsnapshot/register.go +++ b/pkg/registry/apis/dashboardsnapshot/register.go @@ -37,6 +37,7 @@ import ( ) var _ builder.APIGroupBuilder = (*SnapshotsAPIBuilder)(nil) +var _ builder.OpenAPIPostProcessor = (*SnapshotsAPIBuilder)(nil) var resourceInfo = dashboardsnapshot.DashboardSnapshotResourceInfo @@ -183,11 +184,21 @@ func (b *SnapshotsAPIBuilder) GetAPIRoutes() *builder.APIRoutes { { Path: prefix + "/create", Spec: &spec3.PathProps{ - Summary: "an example at the root level", - Description: "longer description here?", Post: &spec3.Operation{ + VendorExtensible: spec.VendorExtensible{ + Extensions: map[string]any{ + "x-grafana-action": "create", + "x-kubernetes-group-version-kind": metav1.GroupVersionKind{ + Group: dashboardsnapshot.GROUP, + Version: dashboardsnapshot.VERSION, + Kind: "DashboardCreateResponse", + }, + }, + }, OperationProps: spec3.OperationProps{ - Tags: tags, + Tags: tags, + Summary: "Full dashboard", + Description: "longer description here?", Parameters: []*spec3.Parameter{ { ParameterProps: spec3.ParameterProps{ @@ -343,3 +354,24 @@ func (b *SnapshotsAPIBuilder) GetAuthorizer() authorizer.Authorizer { return authorizer.DecisionNoOpinion, "", err }) } + +func (b *SnapshotsAPIBuilder) PostProcessOpenAPI(oas *spec3.OpenAPI) (*spec3.OpenAPI, error) { + oas.Info.Description = "A dashboard snapshot shares an interactive dashboard publicly." + + // Set a description on the + sub := oas.Paths.Paths["/apis/dashboardsnapshot.grafana.app/v0alpha1/namespaces/{namespace}/dashboardsnapshots/{name}/body"] + if sub != nil && sub.Get != nil { + sub.Get.Summary = "Full dashboard" + sub.Get.Description = "Read the full dashboard body" + } + + // Hide the invalid endpoint to list all snapshots for all orgs + delete(oas.Paths.Paths, "/apis/dashboardsnapshot.grafana.app/v0alpha1/dashboardsnapshots") + + // The root API discovery list + sub = oas.Paths.Paths["/apis/dashboardsnapshot.grafana.app/v0alpha1/"] + if sub != nil && sub.Get != nil { + sub.Get.Tags = []string{"API Discovery"} // sorts first in the list + } + return oas, nil +} diff --git a/pkg/services/apiserver/builder/common.go b/pkg/services/apiserver/builder/common.go index 39d2c5dccee..0f380c1eafe 100644 --- a/pkg/services/apiserver/builder/common.go +++ b/pkg/services/apiserver/builder/common.go @@ -42,6 +42,11 @@ type APIGroupBuilder interface { GetAuthorizer() authorizer.Authorizer } +// Builders that implement OpenAPIPostProcessor are given a chance to modify the schema directly +type OpenAPIPostProcessor interface { + PostProcessOpenAPI(*spec3.OpenAPI) (*spec3.OpenAPI, error) +} + // This is used to implement dynamic sub-resources like pods/x/logs type APIRouteHandler struct { Path string // added to the appropriate level diff --git a/pkg/services/apiserver/builder/openapi.go b/pkg/services/apiserver/builder/openapi.go index e4d6a2ac149..5a44afa22a3 100644 --- a/pkg/services/apiserver/builder/openapi.go +++ b/pkg/services/apiserver/builder/openapi.go @@ -2,12 +2,15 @@ package builder import ( "maps" + "strings" v1 "k8s.io/apimachinery/pkg/apis/meta/v1" common "k8s.io/kube-openapi/pkg/common" + "k8s.io/kube-openapi/pkg/spec3" spec "k8s.io/kube-openapi/pkg/validation/spec" "github.com/grafana/grafana/pkg/apis/common/v0alpha1" + "github.com/grafana/grafana/pkg/setting" ) // This should eventually live in grafana-app-sdk @@ -26,6 +29,92 @@ func GetOpenAPIDefinitions(builders []APIGroupBuilder) common.GetOpenAPIDefiniti } } +// 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 _, b := range builders { + routes := b.GetAPIRoutes() + gv := b.GetGroupVersion() + prefix := "/apis/" + gv.String() + "/" + if s.Paths.Paths[prefix] != nil { + copy := spec3.OpenAPI{ + Version: s.Version, + Info: &spec.Info{ + InfoProps: spec.InfoProps{ + Title: gv.String(), + Version: setting.BuildVersion, + }, + }, + Components: s.Components, + ExternalDocs: s.ExternalDocs, + Servers: s.Servers, + Paths: s.Paths, + } + + if routes == nil { + routes = &APIRoutes{} + } + + 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, + } + } + + // Make the sub-resources (connect) share the same tags as the main resource + for path, spec := range copy.Paths.Paths { + idx := strings.LastIndex(path, "{name}/") + if idx > 0 { + parent := copy.Paths.Paths[path[:idx+6]] + if parent != nil && parent.Get != nil { + for _, op := range GetPathOperations(spec) { + if op != nil && op.Extensions != nil { + action, ok := op.Extensions.GetString("x-kubernetes-action") + if ok && action == "connect" { + op.Tags = parent.Get.Tags + } + } + } + } + } + } + + // Support direct manipulation of API results + processor, ok := b.(OpenAPIPostProcessor) + if ok { + return processor.PostProcessOpenAPI(©) + } + return ©, nil + } + } + return s, nil + } +} + +func GetPathOperations(path *spec3.Path) []*spec3.Operation { + return []*spec3.Operation{ + path.Get, + path.Head, + path.Delete, + path.Patch, + path.Post, + path.Put, + path.Trace, + path.Options, + } +} + func getStandardOpenAPIDefinitions(ref common.ReferenceCallback) map[string]common.OpenAPIDefinition { return map[string]common.OpenAPIDefinition{ "k8s.io/apimachinery/pkg/apis/meta/v1.APIGroup": schema_pkg_apis_meta_v1_APIGroup(ref), diff --git a/pkg/services/apiserver/builder/request_handler.go b/pkg/services/apiserver/builder/request_handler.go index b88c221b57f..38385d7b778 100644 --- a/pkg/services/apiserver/builder/request_handler.go +++ b/pkg/services/apiserver/builder/request_handler.go @@ -7,9 +7,6 @@ import ( "github.com/gorilla/mux" restclient "k8s.io/client-go/rest" "k8s.io/kube-openapi/pkg/spec3" - "k8s.io/kube-openapi/pkg/validation/spec" - - "github.com/grafana/grafana/pkg/setting" ) type requestHandler struct { @@ -116,53 +113,3 @@ 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 _, b := range builders { - routes := b.GetAPIRoutes() - gv := b.GetGroupVersion() - prefix := "/apis/" + gv.String() + "/" - if s.Paths.Paths[prefix] != nil { - copy := spec3.OpenAPI{ - Version: s.Version, - Info: &spec.Info{ - InfoProps: spec.InfoProps{ - Title: gv.String(), - Version: setting.BuildVersion, - }, - }, - Components: s.Components, - ExternalDocs: s.ExternalDocs, - Servers: s.Servers, - Paths: s.Paths, - } - - if routes == nil { - routes = &APIRoutes{} - } - - 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, - } - } - - return ©, nil - } - } - return s, nil - } -} diff --git a/pkg/services/featuremgmt/registry.go b/pkg/services/featuremgmt/registry.go index 93c1b83dccb..7930514064e 100644 --- a/pkg/services/featuremgmt/registry.go +++ b/pkg/services/featuremgmt/registry.go @@ -951,7 +951,7 @@ var ( }, { Name: "kubernetesSnapshots", - Description: "Use the kubernetes API in the frontend to support playlists", + Description: "Routes snapshot requests from /api to the /apis endpoint", Stage: FeatureStageExperimental, Owner: grafanaAppPlatformSquad, RequiresRestart: true, // changes the API routing diff --git a/pkg/services/featuremgmt/toggles_gen.go b/pkg/services/featuremgmt/toggles_gen.go index 0c7f0594ec6..5d06415a45c 100644 --- a/pkg/services/featuremgmt/toggles_gen.go +++ b/pkg/services/featuremgmt/toggles_gen.go @@ -456,7 +456,7 @@ const ( FlagKubernetesPlaylists = "kubernetesPlaylists" // FlagKubernetesSnapshots - // Use the kubernetes API in the frontend to support playlists + // Routes snapshot requests from /api to the /apis endpoint FlagKubernetesSnapshots = "kubernetesSnapshots" // FlagKubernetesQueryServiceRewrite