From b12a29a1dac8b9aec4a99be08e1665939cb27dc5 Mon Sep 17 00:00:00 2001 From: "Arati R." <33031346+suntala@users.noreply.github.com> Date: Tue, 10 Sep 2024 11:22:08 +0200 Subject: [PATCH] K8s: Implement partial folders api with k8s client (#93089) * Add kubernetes folder feature toggle * Add kubernetes routes for getting and creating a folder * Add documentation for interacting with k8s folders --- .../feature-toggles/index.md | 1 + .../src/types/featureToggles.gen.ts | 1 + pkg/api/api.go | 44 ++++++---- pkg/api/folder.go | 84 +++++++++++++++++++ pkg/registry/apis/folders/conversions.go | 25 ++++++ pkg/services/featuremgmt/registry.go | 6 ++ pkg/services/featuremgmt/toggles_gen.csv | 1 + pkg/services/featuremgmt/toggles_gen.go | 4 + pkg/services/featuremgmt/toggles_gen.json | 15 ++++ pkg/storage/unified/README.md | 56 ++++++++++++- 10 files changed, 219 insertions(+), 18 deletions(-) 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 ed738340c38..a2c302e11fe 100644 --- a/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md +++ b/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md @@ -156,6 +156,7 @@ Experimental features might be changed or removed without prior notice. | `disableClassicHTTPHistogram` | Disables classic HTTP Histogram (use with enableNativeHTTPHistogram) | | `kubernetesSnapshots` | Routes snapshot requests from /api to the /apis endpoint | | `kubernetesDashboards` | Use the kubernetes API in the frontend for dashboards | +| `kubernetesFolders` | Use the kubernetes API in the frontend for folders, and route /api/folders requests to k8s | | `datasourceQueryTypes` | Show query type endpoints in datasource API servers (currently hardcoded for testdata, expressions, and prometheus) | | `queryService` | Register /apis/query.grafana.app/ -- will eventually replace /api/ds/query | | `queryServiceRewrite` | Rewrite requests targeting /ds/query to the query service | diff --git a/packages/grafana-data/src/types/featureToggles.gen.ts b/packages/grafana-data/src/types/featureToggles.gen.ts index 5fcc048cbe4..7f0aa1136fd 100644 --- a/packages/grafana-data/src/types/featureToggles.gen.ts +++ b/packages/grafana-data/src/types/featureToggles.gen.ts @@ -115,6 +115,7 @@ export interface FeatureToggles { kubernetesPlaylists?: boolean; kubernetesSnapshots?: boolean; kubernetesDashboards?: boolean; + kubernetesFolders?: boolean; datasourceQueryTypes?: boolean; queryService?: boolean; queryServiceRewrite?: boolean; diff --git a/pkg/api/api.go b/pkg/api/api.go index eaf4c8c3a0c..9e42b65cb7a 100644 --- a/pkg/api/api.go +++ b/pkg/api/api.go @@ -443,25 +443,35 @@ func (hs *HTTPServer) registerRoutes() { apiRoute.Any("/datasources/uid/:uid/health", requestmeta.SetSLOGroup(requestmeta.SLOGroupHighSlow), authorize(ac.EvalPermission(datasources.ActionQuery)), routing.Wrap(hs.CheckDatasourceHealthWithUID)) // Folders + // #TODO kubernetes folders: move this to its own function, add back auth part, add other routes apiRoute.Group("/folders", func(folderRoute routing.RouteRegister) { - idScope := dashboards.ScopeFoldersProvider.GetResourceScope(ac.Parameter(":id")) - uidScope := dashboards.ScopeFoldersProvider.GetResourceScopeUID(ac.Parameter(":uid")) - folderRoute.Get("/", authorize(ac.EvalPermission(dashboards.ActionFoldersRead)), routing.Wrap(hs.GetFolders)) - folderRoute.Get("/id/:id", authorize(ac.EvalPermission(dashboards.ActionFoldersRead, idScope)), routing.Wrap(hs.GetFolderByID)) - folderRoute.Post("/", authorize(ac.EvalPermission(dashboards.ActionFoldersCreate)), routing.Wrap(hs.CreateFolder)) - - folderRoute.Group("/:uid", func(folderUidRoute routing.RouteRegister) { - folderUidRoute.Get("/", authorize(ac.EvalPermission(dashboards.ActionFoldersRead, uidScope)), routing.Wrap(hs.GetFolderByUID)) - folderUidRoute.Put("/", authorize(ac.EvalPermission(dashboards.ActionFoldersWrite, uidScope)), routing.Wrap(hs.UpdateFolder)) - folderUidRoute.Post("/move", authorize(ac.EvalPermission(dashboards.ActionFoldersWrite, uidScope)), routing.Wrap(hs.MoveFolder)) - folderUidRoute.Delete("/", authorize(ac.EvalPermission(dashboards.ActionFoldersDelete, uidScope)), routing.Wrap(hs.DeleteFolder)) - folderUidRoute.Get("/counts", authorize(ac.EvalPermission(dashboards.ActionFoldersRead, uidScope)), routing.Wrap(hs.GetFolderDescendantCounts)) - - folderUidRoute.Group("/permissions", func(folderPermissionRoute routing.RouteRegister) { - folderPermissionRoute.Get("/", authorize(ac.EvalPermission(dashboards.ActionFoldersPermissionsRead, uidScope)), routing.Wrap(hs.GetFolderPermissionList)) - folderPermissionRoute.Post("/", authorize(ac.EvalPermission(dashboards.ActionFoldersPermissionsWrite, uidScope)), routing.Wrap(hs.UpdateFolderPermissions)) + if hs.Features.IsEnabledGlobally(featuremgmt.FlagKubernetesFolders) { + // Use k8s client to implement legacy API + handler := newFolderK8sHandler(hs) + folderRoute.Post("/", handler.createFolder) + folderRoute.Group("/:uid", func(folderUidRoute routing.RouteRegister) { + folderUidRoute.Get("/", handler.getFolder) }) - }) + } else { + idScope := dashboards.ScopeFoldersProvider.GetResourceScope(ac.Parameter(":id")) + uidScope := dashboards.ScopeFoldersProvider.GetResourceScopeUID(ac.Parameter(":uid")) + folderRoute.Get("/", authorize(ac.EvalPermission(dashboards.ActionFoldersRead)), routing.Wrap(hs.GetFolders)) + folderRoute.Get("/id/:id", authorize(ac.EvalPermission(dashboards.ActionFoldersRead, idScope)), routing.Wrap(hs.GetFolderByID)) + folderRoute.Post("/", authorize(ac.EvalPermission(dashboards.ActionFoldersCreate)), routing.Wrap(hs.CreateFolder)) + + folderRoute.Group("/:uid", func(folderUidRoute routing.RouteRegister) { + folderUidRoute.Get("/", authorize(ac.EvalPermission(dashboards.ActionFoldersRead, uidScope)), routing.Wrap(hs.GetFolderByUID)) + folderUidRoute.Put("/", authorize(ac.EvalPermission(dashboards.ActionFoldersWrite, uidScope)), routing.Wrap(hs.UpdateFolder)) + folderUidRoute.Post("/move", authorize(ac.EvalPermission(dashboards.ActionFoldersWrite, uidScope)), routing.Wrap(hs.MoveFolder)) + folderUidRoute.Delete("/", authorize(ac.EvalPermission(dashboards.ActionFoldersDelete, uidScope)), routing.Wrap(hs.DeleteFolder)) + folderUidRoute.Get("/counts", authorize(ac.EvalPermission(dashboards.ActionFoldersRead, uidScope)), routing.Wrap(hs.GetFolderDescendantCounts)) + + folderUidRoute.Group("/permissions", func(folderPermissionRoute routing.RouteRegister) { + folderPermissionRoute.Get("/", authorize(ac.EvalPermission(dashboards.ActionFoldersPermissionsRead, uidScope)), routing.Wrap(hs.GetFolderPermissionList)) + folderPermissionRoute.Post("/", authorize(ac.EvalPermission(dashboards.ActionFoldersPermissionsWrite, uidScope)), routing.Wrap(hs.UpdateFolderPermissions)) + }) + }) + } }) // Dashboard diff --git a/pkg/api/folder.go b/pkg/api/folder.go index 889168ccdd9..833de98ed14 100644 --- a/pkg/api/folder.go +++ b/pkg/api/folder.go @@ -6,13 +6,22 @@ import ( "net/http" "strconv" + k8sErrors "k8s.io/apimachinery/pkg/api/errors" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/client-go/dynamic" + "github.com/grafana/authlib/claims" "github.com/grafana/grafana/pkg/api/apierrors" "github.com/grafana/grafana/pkg/api/dtos" "github.com/grafana/grafana/pkg/api/response" "github.com/grafana/grafana/pkg/apimachinery/identity" + folderalpha1 "github.com/grafana/grafana/pkg/apis/folder/v0alpha1" "github.com/grafana/grafana/pkg/infra/metrics" + internalfolders "github.com/grafana/grafana/pkg/registry/apis/folders" "github.com/grafana/grafana/pkg/services/accesscontrol" + grafanaapiserver "github.com/grafana/grafana/pkg/services/apiserver" + "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/dashboards/dashboardaccess" @@ -23,6 +32,7 @@ import ( "github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/services/search" "github.com/grafana/grafana/pkg/util" + "github.com/grafana/grafana/pkg/util/errhttp" "github.com/grafana/grafana/pkg/web" ) @@ -619,3 +629,77 @@ type GetFolderDescendantCountsResponse struct { // in: body Body folder.DescendantCounts `json:"body"` } + +type folderK8sHandler struct { + namespacer request.NamespaceMapper + gvr schema.GroupVersionResource + clientConfigProvider grafanaapiserver.DirectRestConfigProvider +} + +//----------------------------------------------------------------------------------------- +// Folder k8s wrapper functions +//----------------------------------------------------------------------------------------- + +func newFolderK8sHandler(hs *HTTPServer) *folderK8sHandler { + return &folderK8sHandler{ + gvr: folderalpha1.FolderResourceInfo.GroupVersionResource(), + namespacer: request.GetNamespaceMapper(hs.Cfg), + clientConfigProvider: hs.clientConfigProvider, + } +} + +func (fk8s *folderK8sHandler) createFolder(c *contextmodel.ReqContext) { + client, ok := fk8s.getClient(c) + if !ok { + return // error is already sent + } + cmd := folder.CreateFolderCommand{} + if err := web.Bind(c.Req, &cmd); err != nil { + c.JsonApiErr(http.StatusBadRequest, "bad request data", err) + return + } + obj := internalfolders.LegacyCreateCommandToUnstructured(cmd) + out, err := client.Create(c.Req.Context(), &obj, v1.CreateOptions{}) + if err != nil { + fk8s.writeError(c, err) + return + } + c.JSON(http.StatusOK, internalfolders.UnstructuredToLegacyFolderDTO(*out)) +} + +func (fk8s *folderK8sHandler) getFolder(c *contextmodel.ReqContext) { + client, ok := fk8s.getClient(c) + if !ok { + return // error is already sent + } + uid := web.Params(c.Req)[":uid"] + out, err := client.Get(c.Req.Context(), uid, v1.GetOptions{}) + if err != nil { + fk8s.writeError(c, err) + return + } + c.JSON(http.StatusOK, internalfolders.UnstructuredToLegacyFolderDTO(*out)) +} + +//----------------------------------------------------------------------------------------- +// Utility functions +//----------------------------------------------------------------------------------------- + +func (fk8s *folderK8sHandler) getClient(c *contextmodel.ReqContext) (dynamic.ResourceInterface, bool) { + dyn, err := dynamic.NewForConfig(fk8s.clientConfigProvider.GetDirectRestConfig(c)) + if err != nil { + c.JsonApiErr(500, "client", err) + return nil, false + } + return dyn.Resource(fk8s.gvr).Namespace(fk8s.namespacer(c.OrgID)), true +} + +func (fk8s *folderK8sHandler) writeError(c *contextmodel.ReqContext, err error) { + //nolint:errorlint + statusError, ok := err.(*k8sErrors.StatusError) + if ok { + c.JsonApiErr(int(statusError.Status().Code), statusError.Status().Message, err) + return + } + errhttp.Write(c.Req.Context(), err, c.Resp) +} diff --git a/pkg/registry/apis/folders/conversions.go b/pkg/registry/apis/folders/conversions.go index d7f2afe02c6..2e1fb92a3f0 100644 --- a/pkg/registry/apis/folders/conversions.go +++ b/pkg/registry/apis/folders/conversions.go @@ -4,7 +4,9 @@ import ( "fmt" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "github.com/grafana/grafana/pkg/api/dtos" "github.com/grafana/grafana/pkg/apimachinery/utils" "github.com/grafana/grafana/pkg/apis/folder/v0alpha1" "github.com/grafana/grafana/pkg/services/apiserver/endpoints/request" @@ -12,6 +14,29 @@ import ( "github.com/grafana/grafana/pkg/services/folder" ) +func LegacyCreateCommandToUnstructured(cmd folder.CreateFolderCommand) unstructured.Unstructured { + // #TODO add other fields + obj := unstructured.Unstructured{ + Object: map[string]interface{}{ + "spec": map[string]interface{}{ + "title": cmd.Title, + }, + }, + } + obj.SetName(cmd.UID) + return obj +} + +func UnstructuredToLegacyFolderDTO(item unstructured.Unstructured) *dtos.Folder { + spec := item.Object["spec"].(map[string]any) + dto := &dtos.Folder{ + UID: item.GetName(), + Title: spec["title"].(string), + // #TODO add other fields + } + return dto +} + func convertToK8sResource(v *folder.Folder, namespacer request.NamespaceMapper) *v0alpha1.Folder { f := &v0alpha1.Folder{ TypeMeta: v0alpha1.FolderResourceInfo.TypeMeta(), diff --git a/pkg/services/featuremgmt/registry.go b/pkg/services/featuremgmt/registry.go index 33c31ccc988..a6ef6a2845a 100644 --- a/pkg/services/featuremgmt/registry.go +++ b/pkg/services/featuremgmt/registry.go @@ -735,6 +735,12 @@ var ( Owner: grafanaAppPlatformSquad, FrontendOnly: true, }, + { + Name: "kubernetesFolders", + Description: "Use the kubernetes API in the frontend for folders, and route /api/folders requests to k8s", + Stage: FeatureStageExperimental, + Owner: grafanaSearchAndStorageSquad, + }, { Name: "datasourceQueryTypes", Description: "Show query type endpoints in datasource API servers (currently hardcoded for testdata, expressions, and prometheus)", diff --git a/pkg/services/featuremgmt/toggles_gen.csv b/pkg/services/featuremgmt/toggles_gen.csv index 8c5e2a0d142..f2a315980b9 100644 --- a/pkg/services/featuremgmt/toggles_gen.csv +++ b/pkg/services/featuremgmt/toggles_gen.csv @@ -96,6 +96,7 @@ transformationsVariableSupport,GA,@grafana/dataviz-squad,false,false,true kubernetesPlaylists,GA,@grafana/grafana-app-platform-squad,false,true,false kubernetesSnapshots,experimental,@grafana/grafana-app-platform-squad,false,true,false kubernetesDashboards,experimental,@grafana/grafana-app-platform-squad,false,false,true +kubernetesFolders,experimental,@grafana/search-and-storage,false,false,false datasourceQueryTypes,experimental,@grafana/grafana-app-platform-squad,false,true,false queryService,experimental,@grafana/grafana-app-platform-squad,false,true,false queryServiceRewrite,experimental,@grafana/grafana-app-platform-squad,false,true,false diff --git a/pkg/services/featuremgmt/toggles_gen.go b/pkg/services/featuremgmt/toggles_gen.go index 743871b7f94..cf5406b00c2 100644 --- a/pkg/services/featuremgmt/toggles_gen.go +++ b/pkg/services/featuremgmt/toggles_gen.go @@ -395,6 +395,10 @@ const ( // Use the kubernetes API in the frontend for dashboards FlagKubernetesDashboards = "kubernetesDashboards" + // FlagKubernetesFolders + // Use the kubernetes API in the frontend for folders, and route /api/folders requests to k8s + FlagKubernetesFolders = "kubernetesFolders" + // FlagDatasourceQueryTypes // Show query type endpoints in datasource API servers (currently hardcoded for testdata, expressions, and prometheus) FlagDatasourceQueryTypes = "datasourceQueryTypes" diff --git a/pkg/services/featuremgmt/toggles_gen.json b/pkg/services/featuremgmt/toggles_gen.json index f3005dbbcda..10a1b9d560f 100644 --- a/pkg/services/featuremgmt/toggles_gen.json +++ b/pkg/services/featuremgmt/toggles_gen.json @@ -1519,6 +1519,21 @@ "hideFromAdminPage": true } }, + { + "metadata": { + "name": "kubernetesFolders", + "resourceVersion": "1725863636605", + "creationTimestamp": "2024-09-09T06:29:38Z", + "annotations": { + "grafana.app/updatedTimestamp": "2024-09-09 06:33:56.605329 +0000 UTC" + } + }, + "spec": { + "description": "Use the kubernetes API in the frontend for folders, and route /api/folders requests to k8s", + "stage": "experimental", + "codeowner": "@grafana/search-and-storage" + } + }, { "metadata": { "name": "kubernetesPlaylists", diff --git a/pkg/storage/unified/README.md b/pkg/storage/unified/README.md index 903387ab86a..8fbf4110610 100644 --- a/pkg/storage/unified/README.md +++ b/pkg/storage/unified/README.md @@ -13,7 +13,7 @@ There are 2 main tables, the `resource` table stores a "current" view of the obj ## Running Unified Storage -### Baseline configuration +### Playlists: baseline configuration The minimum config settings required are: @@ -34,6 +34,32 @@ kubernetesPlaylists = true storage_type = unified ``` +### Folders: baseline configuration + +NOTE: allowing folders to be backed by Unified Storage is under development and so are these instructions. + +The minimum config settings required are: + +```ini +; need to specify target here for override to work later +target = all + +[server] +; https is required for kubectl +protocol = https + +[feature_toggles] +; store folders in k8s +kubernetesFolders = true +grafanaAPIServerWithExperimentalAPIs = true + +[grafana-apiserver] +; use unified storage for k8s apiserver +storage_type = unified +``` + +### Setting up a kubeconfig + With this configuration, you can run everything in-process. Run the Grafana backend with: ```sh @@ -75,6 +101,8 @@ Where `` and `` are credentials for basic auth against Grafa password: admin ``` +### Playlists: interacting with the k8s API + In this mode, you can interact with the k8s api. Make sure you are in the directory where you created `grafana.kubeconfig`. Then run: ```sh kubectl --kubeconfig=./grafana.kubeconfig get playlist @@ -132,6 +160,32 @@ kubectl --kubeconfig=./grafana.kubeconfig patch playlist --patch-file pla In the example, `` would be `u394j4d3-s63j-2d74-g8hf-958773jtybf2`. +### Folders: interacting with the k8s API + +Make sure you are in the directory where you created `grafana.kubeconfig`. Then run: +```sh +kubectl --kubeconfig=./grafana.kubeconfig get folder +``` + +If this is your first time running the command, a successful response would be: +```sh +No resources found in default namespace. +``` + +To create a folder, create a file `folder-generate.yaml`: +```yaml +apiVersion: folder.grafana.app/v0alpha1 +kind: Folder +metadata: + generateName: x # anything is ok here... except yes or true -- they become boolean! +spec: + title: Example folder +``` +then run: +```sh +kubectl --kubeconfig=./grafana.kubeconfig create -f folder-generate.yaml +``` + ### Use a separate database By default Unified Storage uses the Grafana database. To run against a separate database, update `custom.ini` by adding the following section to it: