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
This commit is contained in:
Arati R.
2024-09-10 11:22:08 +02:00
committed by GitHub
parent 442bb1d35f
commit b12a29a1da
10 changed files with 219 additions and 18 deletions

View File

@@ -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 |

View File

@@ -115,6 +115,7 @@ export interface FeatureToggles {
kubernetesPlaylists?: boolean;
kubernetesSnapshots?: boolean;
kubernetesDashboards?: boolean;
kubernetesFolders?: boolean;
datasourceQueryTypes?: boolean;
queryService?: boolean;
queryServiceRewrite?: boolean;

View File

@@ -443,7 +443,16 @@ 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) {
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))
@@ -462,6 +471,7 @@ func (hs *HTTPServer) registerRoutes() {
folderPermissionRoute.Post("/", authorize(ac.EvalPermission(dashboards.ActionFoldersPermissionsWrite, uidScope)), routing.Wrap(hs.UpdateFolderPermissions))
})
})
}
})
// Dashboard

View File

@@ -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)
}

View File

@@ -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(),

View File

@@ -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)",

View File

@@ -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
1 Name Stage Owner requiresDevMode RequiresRestart FrontendOnly
96 kubernetesPlaylists GA @grafana/grafana-app-platform-squad false true false
97 kubernetesSnapshots experimental @grafana/grafana-app-platform-squad false true false
98 kubernetesDashboards experimental @grafana/grafana-app-platform-squad false false true
99 kubernetesFolders experimental @grafana/search-and-storage false false false
100 datasourceQueryTypes experimental @grafana/grafana-app-platform-squad false true false
101 queryService experimental @grafana/grafana-app-platform-squad false true false
102 queryServiceRewrite experimental @grafana/grafana-app-platform-squad false true false

View File

@@ -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"

View File

@@ -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",

View File

@@ -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 `<username>` and `<password>` 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 <NAME> --patch-file pla
In the example, `<NAME>` 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: