diff --git a/pkg/api/api.go b/pkg/api/api.go index e79d338a912..a774627ba5d 100644 --- a/pkg/api/api.go +++ b/pkg/api/api.go @@ -443,39 +443,7 @@ 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.Get("/", handler.searchFolders) - folderRoute.Post("/", handler.createFolder) - folderRoute.Group("/:uid", func(folderUidRoute routing.RouteRegister) { - folderUidRoute.Get("/", handler.getFolder) - folderUidRoute.Delete("/", handler.deleteFolder) - folderUidRoute.Put("/:uid", handler.updateFolder) - }) - } 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)) - }) - }) - } - }) + hs.registerFolderAPI(apiRoute, authorize) // Dashboard apiRoute.Group("/dashboards", func(dashboardRoute routing.RouteRegister) { diff --git a/pkg/api/folder.go b/pkg/api/folder.go index 1433e12422b..5456100cd0f 100644 --- a/pkg/api/folder.go +++ b/pkg/api/folder.go @@ -16,6 +16,7 @@ import ( "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/api/routing" "github.com/grafana/grafana/pkg/apimachinery/identity" folderalpha1 "github.com/grafana/grafana/pkg/apis/folder/v0alpha1" "github.com/grafana/grafana/pkg/infra/metrics" @@ -39,6 +40,42 @@ import ( const REDACTED = "redacted" +func (hs *HTTPServer) registerFolderAPI(apiRoute routing.RouteRegister, authorize func(accesscontrol.Evaluator) web.Handler) { + // #TODO add back auth part + apiRoute.Group("/folders", func(folderRoute routing.RouteRegister) { + if hs.Features.IsEnabledGlobally(featuremgmt.FlagKubernetesFolders) { + // Use k8s client to implement legacy API + handler := newFolderK8sHandler(hs) + folderRoute.Get("/", handler.searchFolders) + folderRoute.Post("/", handler.createFolder) + folderRoute.Group("/:uid", func(folderUidRoute routing.RouteRegister) { + folderUidRoute.Get("/", handler.getFolder) + folderUidRoute.Delete("/", handler.deleteFolder) + folderUidRoute.Put("/:uid", handler.updateFolder) + }) + } else { + idScope := dashboards.ScopeFoldersProvider.GetResourceScope(accesscontrol.Parameter(":id")) + uidScope := dashboards.ScopeFoldersProvider.GetResourceScopeUID(accesscontrol.Parameter(":uid")) + folderRoute.Get("/", authorize(accesscontrol.EvalPermission(dashboards.ActionFoldersRead)), routing.Wrap(hs.GetFolders)) + folderRoute.Get("/id/:id", authorize(accesscontrol.EvalPermission(dashboards.ActionFoldersRead, idScope)), routing.Wrap(hs.GetFolderByID)) + folderRoute.Post("/", authorize(accesscontrol.EvalPermission(dashboards.ActionFoldersCreate)), routing.Wrap(hs.CreateFolder)) + + folderRoute.Group("/:uid", func(folderUidRoute routing.RouteRegister) { + folderUidRoute.Get("/", authorize(accesscontrol.EvalPermission(dashboards.ActionFoldersRead, uidScope)), routing.Wrap(hs.GetFolderByUID)) + folderUidRoute.Put("/", authorize(accesscontrol.EvalPermission(dashboards.ActionFoldersWrite, uidScope)), routing.Wrap(hs.UpdateFolder)) + folderUidRoute.Post("/move", authorize(accesscontrol.EvalPermission(dashboards.ActionFoldersWrite, uidScope)), routing.Wrap(hs.MoveFolder)) + folderUidRoute.Delete("/", authorize(accesscontrol.EvalPermission(dashboards.ActionFoldersDelete, uidScope)), routing.Wrap(hs.DeleteFolder)) + folderUidRoute.Get("/counts", authorize(accesscontrol.EvalPermission(dashboards.ActionFoldersRead, uidScope)), routing.Wrap(hs.GetFolderDescendantCounts)) + + folderUidRoute.Group("/permissions", func(folderPermissionRoute routing.RouteRegister) { + folderPermissionRoute.Get("/", authorize(accesscontrol.EvalPermission(dashboards.ActionFoldersPermissionsRead, uidScope)), routing.Wrap(hs.GetFolderPermissionList)) + folderPermissionRoute.Post("/", authorize(accesscontrol.EvalPermission(dashboards.ActionFoldersPermissionsWrite, uidScope)), routing.Wrap(hs.UpdateFolderPermissions)) + }) + }) + } + }) +} + // swagger:route GET /folders folders getFolders // // Get all folders. @@ -680,18 +717,28 @@ func (fk8s *folderK8sHandler) createFolder(c *contextmodel.ReqContext) { if !ok { return // error is already sent } - cmd := folder.UpdateFolderCommand{} + cmd := folder.CreateFolderCommand{} if err := web.Bind(c.Req, &cmd); err != nil { c.JsonApiErr(http.StatusBadRequest, "bad request data", err) return } - obj := internalfolders.LegacyUpdateCommandToUnstructured(cmd) + obj, err := internalfolders.LegacyCreateCommandToUnstructured(cmd) + if err != nil { + fk8s.writeError(c, err) + return + } 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)) + + f, err := internalfolders.UnstructuredToLegacyFolderDTO(*out) + if err != nil { + fk8s.writeError(c, err) + return + } + c.JSON(http.StatusOK, f) } func (fk8s *folderK8sHandler) getFolder(c *contextmodel.ReqContext) { @@ -705,7 +752,14 @@ func (fk8s *folderK8sHandler) getFolder(c *contextmodel.ReqContext) { fk8s.writeError(c, err) return } - c.JSON(http.StatusOK, internalfolders.UnstructuredToLegacyFolderDTO(*out)) + + f, err := internalfolders.UnstructuredToLegacyFolderDTO(*out) + if err != nil { + fk8s.writeError(c, err) + return + } + + c.JSON(http.StatusOK, f) } func (fk8s *folderK8sHandler) deleteFolder(c *contextmodel.ReqContext) { @@ -740,7 +794,14 @@ func (fk8s *folderK8sHandler) updateFolder(c *contextmodel.ReqContext) { fk8s.writeError(c, err) return } - c.JSON(http.StatusOK, internalfolders.UnstructuredToLegacyFolderDTO(*out)) + + f, err := internalfolders.UnstructuredToLegacyFolderDTO(*out) + if err != nil { + fk8s.writeError(c, err) + return + } + + c.JSON(http.StatusOK, f) } //----------------------------------------------------------------------------------------- diff --git a/pkg/registry/apis/folders/conversions.go b/pkg/registry/apis/folders/conversions.go index 75c13777af1..e70a5dff7d0 100644 --- a/pkg/registry/apis/folders/conversions.go +++ b/pkg/registry/apis/folders/conversions.go @@ -2,6 +2,8 @@ package folders import ( "fmt" + "strconv" + "time" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" @@ -9,11 +11,32 @@ import ( "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/infra/slugify" "github.com/grafana/grafana/pkg/services/apiserver/endpoints/request" gapiutil "github.com/grafana/grafana/pkg/services/apiserver/utils" + "github.com/grafana/grafana/pkg/services/dashboards" "github.com/grafana/grafana/pkg/services/folder" ) +func LegacyCreateCommandToUnstructured(cmd folder.CreateFolderCommand) (unstructured.Unstructured, error) { + obj := unstructured.Unstructured{ + Object: map[string]interface{}{ + "spec": map[string]interface{}{ + "title": cmd.Title, + "description": cmd.Description, + }, + }, + } + // #TODO: let's see if we need to set the json field to "-" + obj.SetName(cmd.UID) + + if err := setParentUID(&obj, cmd.ParentUID); err != nil { + return unstructured.Unstructured{}, err + } + + return obj, nil +} + func LegacyUpdateCommandToUnstructured(cmd folder.UpdateFolderCommand) unstructured.Unstructured { // #TODO add other fields obj := unstructured.Unstructured{ @@ -36,17 +59,55 @@ func UnstructuredToLegacyFolder(item unstructured.Unstructured) *folder.Folder { } } -func UnstructuredToLegacyFolderDTO(item unstructured.Unstructured) *dtos.Folder { +func UnstructuredToLegacyFolderDTO(item unstructured.Unstructured) (*dtos.Folder, error) { spec := item.Object["spec"].(map[string]any) - dto := &dtos.Folder{ - UID: item.GetName(), - Title: spec["title"].(string), - // #TODO add other fields + uid := item.GetName() + title := spec["title"].(string) + + meta, err := utils.MetaAccessor(&item) + if err != nil { + return nil, err } - return dto + + id, err := getLegacyID(meta) + if err != nil { + return nil, err + } + + created, err := getCreated(meta) + if err != nil { + return nil, err + } + + dto := &dtos.Folder{ + UID: uid, + Title: title, + ID: id, + ParentUID: meta.GetFolder(), + // #TODO add back CreatedBy, UpdatedBy once we figure out how to access userService + // to translate user ID into user login. meta.GetCreatedBy() only stores user ID + // Could convert meta.GetCreatedBy() return value to a struct--id and name + // CreatedBy: meta.GetCreatedBy(), + // UpdatedBy: meta.GetCreatedBy(), + URL: getURL(meta, title), + // #TODO get Created in format "2024-09-12T15:37:41.09466+02:00" + Created: *created, + // #TODO figure out whether we want to set "updated" and "updated by". Could replace with + // meta.GetUpdatedTimestamp() but it currently gets overwritten in prepareObjectForStorage(). + Updated: *created, + // #TODO figure out how to set these properly + CanSave: true, + CanEdit: true, + CanAdmin: true, + CanDelete: true, + HasACL: false, + + // #TODO figure out about adding version, parents, orgID fields + } + return dto, nil } -func convertToK8sResource(v *folder.Folder, namespacer request.NamespaceMapper) *v0alpha1.Folder { +func convertToK8sResource(v *folder.Folder, namespacer request.NamespaceMapper) (*v0alpha1.Folder, error) { f := &v0alpha1.Folder{ TypeMeta: v0alpha1.FolderResourceInfo.TypeMeta(), ObjectMeta: metav1.ObjectMeta{ @@ -62,24 +123,67 @@ func convertToK8sResource(v *folder.Folder, namespacer request.NamespaceMapper) } meta, err := utils.MetaAccessor(f) - if err == nil { - meta.SetUpdatedTimestamp(&v.Updated) - if v.ID > 0 { // nolint:staticcheck - meta.SetOriginInfo(&utils.ResourceOriginInfo{ - Name: "SQL", - Path: fmt.Sprintf("%d", v.ID), // nolint:staticcheck - }) - } - if v.CreatedBy > 0 { - meta.SetCreatedBy(fmt.Sprintf("user:%d", v.CreatedBy)) - } - if v.UpdatedBy > 0 { - meta.SetUpdatedBy(fmt.Sprintf("user:%d", v.UpdatedBy)) - } + if err != nil { + return nil, err + } + + meta.SetUpdatedTimestamp(&v.Updated) + if v.ID > 0 { // nolint:staticcheck + meta.SetOriginInfo(&utils.ResourceOriginInfo{ + Name: "SQL", + Path: fmt.Sprintf("%d", v.ID), // nolint:staticcheck + Timestamp: &v.Created, + }) + } + if v.CreatedBy > 0 { + meta.SetCreatedBy(fmt.Sprintf("user:%d", v.CreatedBy)) + } + if v.UpdatedBy > 0 { + meta.SetUpdatedBy(fmt.Sprintf("user:%d", v.UpdatedBy)) } if v.ParentUID != "" { meta.SetFolder(v.ParentUID) } f.UID = gapiutil.CalculateClusterWideUID(f) - return f + return f, nil +} + +func setParentUID(u *unstructured.Unstructured, parentUid string) error { + meta, err := utils.MetaAccessor(u) + if err != nil { + return err + } + meta.SetFolder(parentUid) + return nil +} + +func getLegacyID(meta utils.GrafanaMetaAccessor) (int64, error) { + var i int64 + + info, err := meta.GetOriginInfo() + if err != nil { + return i, err + } + + if info != nil && info.Name == "SQL" { + i, err = strconv.ParseInt(info.Path, 10, 64) + if err != nil { + return i, err + } + } + return i, nil +} + +func getURL(meta utils.GrafanaMetaAccessor, title string) string { + slug := slugify.Slugify(title) + uid := meta.GetName() + return dashboards.GetFolderURL(uid, slug) +} + +func getCreated(meta utils.GrafanaMetaAccessor) (*time.Time, error) { + created, err := meta.GetOriginTimestamp() + if err != nil { + return nil, err + } + return created, nil } diff --git a/pkg/registry/apis/folders/legacy_storage.go b/pkg/registry/apis/folders/legacy_storage.go index e6986d51c75..283c5d47887 100644 --- a/pkg/registry/apis/folders/legacy_storage.go +++ b/pkg/registry/apis/folders/legacy_storage.go @@ -101,7 +101,11 @@ func (s *legacyStorage) List(ctx context.Context, options *internalversion.ListO list := &v0alpha1.FolderList{} for _, v := range hits { - list.Items = append(list.Items, *convertToK8sResource(v, s.namespacer)) + r, err := convertToK8sResource(v, s.namespacer) + if err != nil { + return nil, err + } + list.Items = append(list.Items, *r) } if len(list.Items) >= int(paging.limit) { list.Continue = paging.GetNextPageToken() @@ -132,7 +136,12 @@ func (s *legacyStorage) Get(ctx context.Context, name string, options *metav1.Ge return nil, err } - return convertToK8sResource(dto, s.namespacer), nil + r, err := convertToK8sResource(dto, s.namespacer) + if err != nil { + return nil, err + } + + return r, nil } func (s *legacyStorage) Create(ctx context.Context, @@ -178,7 +187,15 @@ func (s *legacyStorage) Create(ctx context.Context, if err != nil { return nil, err } - return s.Get(ctx, out.UID, nil) + // #TODO can we directly convert instead of doing a Get? the result of the Create + // has more data than the one of Get so there is more we can include in the k8s resource + // this way + + r, err := convertToK8sResource(out, s.namespacer) + if err != nil { + return nil, err + } + return r, nil } func (s *legacyStorage) Update(ctx context.Context,