Compare commits

...

18 Commits

Author SHA1 Message Date
Ryan McKinley
dfed7ae90c merge main 2025-12-22 13:48:53 +03:00
Ryan McKinley
4c39335c6d merge main 2025-12-22 12:55:56 +03:00
Ryan McKinley
4a20665cf7 lint fix 2025-12-19 07:57:23 +03:00
Ryan McKinley
50bf29b050 should not have a real change 2025-12-19 07:55:41 +03:00
Ryan McKinley
a311bd0e84 Merge remote-tracking branch 'origin/main' into ensure-folder-annotation-when-supported 2025-12-19 07:54:52 +03:00
Ryan McKinley
71fda4fa42 do not actually write the value 2025-12-18 10:47:07 +03:00
Ryan McKinley
b605f464a4 Merge remote-tracking branch 'origin/main' into ensure-folder-annotation-when-supported 2025-12-18 10:46:18 +03:00
Ryan McKinley
a95b28ab19 Merge remote-tracking branch 'origin/main' into ensure-folder-annotation-when-supported 2025-12-18 09:44:15 +03:00
Ryan McKinley
a82253e63a keep nil unless values exist 2025-12-03 12:43:44 +03:00
Ryan McKinley
df4f58fa75 Merge remote-tracking branch 'origin/main' into ensure-folder-annotation-when-supported 2025-12-03 12:21:30 +03:00
Ryan McKinley
614248c63d annotations 2025-12-02 16:28:45 +03:00
Ryan McKinley
ba038e2848 remove from legacy folder api 2025-12-02 15:47:47 +03:00
Ryan McKinley
53eead1fa5 another test works 2025-12-02 15:22:57 +03:00
Ryan McKinley
4c190fa6c2 Merge remote-tracking branch 'origin/main' into ensure-folder-annotation-when-supported 2025-12-02 15:02:28 +03:00
Ryan McKinley
b1ff3eb2f1 remove general folder in legacy api 2025-12-02 14:48:02 +03:00
Ryan McKinley
3cb29d02ad Merge remote-tracking branch 'origin/main' into ensure-folder-annotation-when-supported 2025-12-02 14:23:06 +03:00
Ryan McKinley
ff4f2b3926 remove general folder in legacy api 2025-12-02 14:22:46 +03:00
Ryan McKinley
9d5659bfba ensure folder annotation 2025-12-02 13:20:13 +03:00
12 changed files with 92 additions and 38 deletions

View File

@@ -54,6 +54,7 @@ import (
"github.com/grafana/grafana/pkg/services/dashboardsnapshots" "github.com/grafana/grafana/pkg/services/dashboardsnapshots"
"github.com/grafana/grafana/pkg/services/datasources" "github.com/grafana/grafana/pkg/services/datasources"
"github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/services/folder"
"github.com/grafana/grafana/pkg/services/libraryelements" "github.com/grafana/grafana/pkg/services/libraryelements"
"github.com/grafana/grafana/pkg/services/librarypanels" "github.com/grafana/grafana/pkg/services/librarypanels"
"github.com/grafana/grafana/pkg/services/live" "github.com/grafana/grafana/pkg/services/live"
@@ -388,8 +389,12 @@ func (b *DashboardsAPIBuilder) validateCreate(ctx context.Context, a admission.A
return fmt.Errorf("error getting requester: %w", err) return fmt.Errorf("error getting requester: %w", err)
} }
if a.IsDryRun() {
return nil // do not check folder or quota
}
// Validate folder existence if specified // Validate folder existence if specified
if !a.IsDryRun() && accessor.GetFolder() != "" { if !folder.IsRootFolder(accessor.GetFolder()) {
folder, err := b.validateFolderExists(ctx, accessor.GetFolder(), id.GetOrgID()) folder, err := b.validateFolderExists(ctx, accessor.GetFolder(), id.GetOrgID())
if err != nil { if err != nil {
return err return err
@@ -401,7 +406,7 @@ func (b *DashboardsAPIBuilder) validateCreate(ctx context.Context, a admission.A
} }
// Validate quota // Validate quota
if !b.isStandalone && !a.IsDryRun() { if !b.isStandalone {
params := &quota.ScopeParameters{} params := &quota.ScopeParameters{}
params.OrgID = id.GetOrgID() params.OrgID = id.GetOrgID()
internalId, err := id.GetInternalID() internalId, err := id.GetInternalID()

View File

@@ -46,12 +46,12 @@ func validateOnCreate(ctx context.Context, f *folders.Folder, getter parentsGett
return dashboards.ErrFolderTitleEmpty return dashboards.ErrFolderTitleEmpty
} }
parentName := meta.GetFolder() switch meta.GetFolder() {
if parentName == "" { case "", folder.GeneralFolderUID:
return nil // OK, we do not need to validate the tree return nil // OK, we do not need to validate the tree
} case folder.SharedWithMeFolderUID:
return fmt.Errorf("can not save shared with me")
if parentName == f.Name { case f.Name:
return folder.ErrFolderCannotBeParentOfItself return folder.ErrFolderCannotBeParentOfItself
} }
@@ -98,17 +98,19 @@ func validateOnUpdate(ctx context.Context,
// Validate the move operation // Validate the move operation
newParent := folderObj.GetFolder() newParent := folderObj.GetFolder()
switch newParent {
// If we move to root, we don't need to validate the depth, because the folder already existed // If we move to root, we don't need to validate the depth, because the folder already existed
// before and wasn't too deep. This move will make it more shallow. // before and wasn't too deep. This move will make it more shallow.
// //
// We also don't need to validate circular references because the root folder cannot have a parent. // We also don't need to validate circular references because the root folder cannot have a parent.
if newParent == folder.RootFolderUID { case "", folder.GeneralFolderUID:
return nil return nil // OK, we do not need to validate the tree
} case folder.SharedWithMeFolderUID:
return fmt.Errorf("can not save shared with me")
// folder cannot be moved to a k6 folder case accesscontrol.K6FolderUID:
if newParent == accesscontrol.K6FolderUID {
return fmt.Errorf("k6 project may not be moved") return fmt.Errorf("k6 project may not be moved")
case folderObj.GetName():
return folder.ErrFolderCannotBeParentOfItself
} }
parentObj, err := getter.Get(ctx, newParent, &metav1.GetOptions{}) parentObj, err := getter.Get(ctx, newParent, &metav1.GetOptions{})

View File

@@ -2344,13 +2344,18 @@ func (dr *DashboardServiceImpl) unstructuredToLegacyDashboardWithUsers(item *uns
dashVersion := obj.GetGeneration() dashVersion := obj.GetGeneration()
spec["version"] = dashVersion spec["version"] = dashVersion
folderUID := obj.GetFolder()
if folderUID == folder.GeneralFolderUID {
folderUID = "" // empty in legacy API
}
title, _, _ := unstructured.NestedString(spec, "title") title, _, _ := unstructured.NestedString(spec, "title")
out := dashboards.Dashboard{ out := dashboards.Dashboard{
OrgID: orgID, OrgID: orgID,
ID: obj.GetDeprecatedInternalID(), // nolint:staticcheck ID: obj.GetDeprecatedInternalID(), // nolint:staticcheck
UID: uid, UID: uid,
Slug: slugify.Slugify(title), Slug: slugify.Slugify(title),
FolderUID: obj.GetFolder(), FolderUID: folderUID,
Version: int(dashVersion), Version: int(dashVersion),
Data: simplejson.NewFromAny(spec), Data: simplejson.NewFromAny(spec),
APIVersion: strings.TrimPrefix(item.GetAPIVersion(), dashboardv0.GROUP+"/"), APIVersion: strings.TrimPrefix(item.GetAPIVersion(), dashboardv0.GROUP+"/"),

View File

@@ -32,7 +32,7 @@ func convertUnstructuredToFolder(item *unstructured.Unstructured, identifiers ma
uid := meta.GetName() uid := meta.GetName()
url := "" url := ""
if uid != folder.RootFolder.UID { if !folder.IsRootFolder(uid) {
slug := slugify.Slugify(title) slug := slugify.Slugify(title)
url = dashboards.GetFolderURL(uid, slug) url = dashboards.GetFolderURL(uid, slug)
} }
@@ -62,13 +62,18 @@ func convertUnstructuredToFolder(item *unstructured.Unstructured, identifiers ma
} }
} }
parent := meta.GetFolder()
if folder.IsRootFolder(parent) {
parent = ""
}
manager, _ := meta.GetManagerProperties() manager, _ := meta.GetManagerProperties()
return &folder.Folder{ return &folder.Folder{
UID: uid, UID: uid,
Title: title, Title: title,
Description: description, Description: description,
ID: meta.GetDeprecatedInternalID(), // nolint:staticcheck ID: meta.GetDeprecatedInternalID(), // nolint:staticcheck
ParentUID: meta.GetFolder(), ParentUID: parent,
Version: int(meta.GetGeneration()), Version: int(meta.GetGeneration()),
ManagedBy: manager.Kind, ManagedBy: manager.Kind,

View File

@@ -1283,6 +1283,10 @@ func (s *Service) buildSaveDashboardCommand(ctx context.Context, dto *dashboards
return nil, dashboards.ErrDashboardFolderNameExists return nil, dashboards.ErrDashboardFolderNameExists
} }
if dash.FolderUID == folder.GeneralFolderUID {
dash.FolderUID = "" // general is the same as root
}
if dash.FolderUID != "" { if dash.FolderUID != "" {
if _, err := s.dashboardFolderStore.GetFolderByUID(ctx, dash.OrgID, dash.FolderUID); err != nil { if _, err := s.dashboardFolderStore.GetFolderByUID(ctx, dash.OrgID, dash.FolderUID); err != nil {
return nil, err return nil, err
@@ -1380,7 +1384,7 @@ func SplitFullpath(s string) []string {
func (s *Service) nestedFolderCreate(ctx context.Context, cmd *folder.CreateFolderCommand) (*folder.Folder, error) { func (s *Service) nestedFolderCreate(ctx context.Context, cmd *folder.CreateFolderCommand) (*folder.Folder, error) {
ctx, span := s.tracer.Start(ctx, "folder.nestedFolderCreate") ctx, span := s.tracer.Start(ctx, "folder.nestedFolderCreate")
defer span.End() defer span.End()
if cmd.ParentUID != "" { if !folder.IsRootFolder(cmd.ParentUID) {
if err := s.validateParent(ctx, cmd.OrgID, cmd.ParentUID, cmd.UID); err != nil { if err := s.validateParent(ctx, cmd.OrgID, cmd.ParentUID, cmd.UID); err != nil {
return nil, err return nil, err
} }

View File

@@ -180,7 +180,7 @@ func (ss *FolderUnifiedStoreImpl) GetParents(ctx context.Context, q folder.GetPa
hits := []*folder.Folder{} hits := []*folder.Folder{}
parentUID := q.UID parentUID := q.UID
for parentUID != "" { for !folder.IsRootFolder(parentUID) {
folder, err := ss.Get(ctx, folder.GetFolderQuery{UID: &parentUID, OrgID: q.OrgID}) folder, err := ss.Get(ctx, folder.GetFolderQuery{UID: &parentUID, OrgID: q.OrgID})
if err != nil { if err != nil {
if apierrors.IsForbidden(err) { if apierrors.IsForbidden(err) {

View File

@@ -31,6 +31,10 @@ const (
SharedWithMeFolderUID = "sharedwithme" SharedWithMeFolderUID = "sharedwithme"
) )
func IsRootFolder(f string) bool {
return f == "" || f == GeneralFolderUID
}
var ErrFolderNotFound = errutil.NotFound("folder.notFound") var ErrFolderNotFound = errutil.NotFound("folder.notFound")
type Folder struct { type Folder struct {

View File

@@ -76,6 +76,20 @@ func (v *objectForStorage) finish(ctx context.Context, err error, secrets secret
return nil return nil
} }
func (s *Storage) verifyFolder(obj utils.GrafanaMetaAccessor) error {
if s.opts.EnableFolderSupport {
if obj.GetFolder() == "" {
// return apierrors.NewBadRequest("missing folder annotation")
// TODO?: should this be optionally be done in a mutation webhook?
// ???? obj.SetFolder(folder.GeneralFolderUID) // always enter something
return nil
}
} else if obj.GetFolder() != "" {
return apierrors.NewBadRequest("folders not supported in this resource")
}
return nil
}
// Called on create // Called on create
func (s *Storage) prepareObjectForStorage(ctx context.Context, newObject runtime.Object) (objectForStorage, error) { func (s *Storage) prepareObjectForStorage(ctx context.Context, newObject runtime.Object) (objectForStorage, error) {
v := objectForStorage{} v := objectForStorage{}
@@ -103,6 +117,9 @@ func (s *Storage) prepareObjectForStorage(ctx context.Context, newObject runtime
if s.opts.MaximumNameLength > 0 && len(obj.GetName()) > s.opts.MaximumNameLength { if s.opts.MaximumNameLength > 0 && len(obj.GetName()) > s.opts.MaximumNameLength {
return v, apierrors.NewBadRequest(fmt.Sprintf("name exceeds maximum length (%d)", s.opts.MaximumNameLength)) return v, apierrors.NewBadRequest(fmt.Sprintf("name exceeds maximum length (%d)", s.opts.MaximumNameLength))
} }
if err = s.verifyFolder(obj); err != nil {
return v, err
}
v.grantPermissions = obj.GetAnnotation(utils.AnnoKeyGrantPermissions) v.grantPermissions = obj.GetAnnotation(utils.AnnoKeyGrantPermissions)
if v.grantPermissions != "" { if v.grantPermissions != "" {
@@ -193,17 +210,15 @@ func (s *Storage) prepareObjectForUpdate(ctx context.Context, updateObject runti
obj.SetDeprecatedInternalID(previousInternalID) // nolint:staticcheck obj.SetDeprecatedInternalID(previousInternalID) // nolint:staticcheck
} }
err = prepareSecureValues(ctx, s.opts.SecureValues, obj, previous, &v) if err = prepareSecureValues(ctx, s.opts.SecureValues, obj, previous, &v); err != nil {
if err != nil { return v, err
}
if err = s.verifyFolder(obj); err != nil {
return v, err return v, err
} }
// Check if we should bump the generation // Check if we should bump the generation
if obj.GetFolder() != previous.GetFolder() { if obj.GetFolder() != previous.GetFolder() {
if !s.opts.EnableFolderSupport {
return v, apierrors.NewBadRequest(fmt.Sprintf("folders are not supported for: %s", s.gr.String()))
}
// TODO: check that we can move the folder?
v.hasChanged = true v.hasChanged = true
} else if obj.GetDeletionTimestamp() != nil && previous.GetDeletionTimestamp() == nil { } else if obj.GetDeletionTimestamp() != nil && previous.GetDeletionTimestamp() == nil {
v.hasChanged = true // bump generation when deleted v.hasChanged = true // bump generation when deleted

View File

@@ -209,6 +209,13 @@ func (s *Storage) convertToObject(ctx context.Context, data []byte, obj runtime.
_, span := tracer.Start(ctx, "apistore.Storage.convertToObject") _, span := tracer.Start(ctx, "apistore.Storage.convertToObject")
defer span.End() defer span.End()
obj, _, err := s.codec.Decode(data, nil, obj) obj, _, err := s.codec.Decode(data, nil, obj)
// TODO!!! Replace empty folder with "general" on read (this was not a requirement early on)
// if s.opts.EnableFolderSupport {
// m, _ := utils.MetaAccessor(obj)
// if m != nil && m.GetFolder() == "" {
// m.SetFolder(folder.GeneralFolderUID)
// }
// }
return obj, err return obj, err
} }

View File

@@ -1166,13 +1166,13 @@ func TestIntegrationDashboardServicePermissions(t *testing.T) {
resp, err := postDashboard(t, grafanaListedAddr, "viewer", "viewer", dashboardPayload) resp, err := postDashboard(t, grafanaListedAddr, "viewer", "viewer", dashboardPayload)
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, http.StatusForbidden, resp.StatusCode) require.Equal(t, http.StatusForbidden, resp.StatusCode)
err = resp.Body.Close() err = resp.Body.Close()
require.NoError(t, err) require.NoError(t, err)
resp, err = postDashboard(t, grafanaListedAddr, "editor", "editor", dashboardPayload) resp, err = postDashboard(t, grafanaListedAddr, "editor", "editor", dashboardPayload)
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, http.StatusOK, resp.StatusCode) require.Equal(t, http.StatusOK, resp.StatusCode)
err = resp.Body.Close() err = resp.Body.Close()
require.NoError(t, err) require.NoError(t, err)
}) })

View File

@@ -9,6 +9,7 @@ import (
"strings" "strings"
"testing" "testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"github.com/xlab/treeprint" "github.com/xlab/treeprint"
apierrors "k8s.io/apimachinery/pkg/api/errors" apierrors "k8s.io/apimachinery/pkg/api/errors"
@@ -377,7 +378,7 @@ func getFoldersFromLegacyAPISearch(t *testing.T, client *rest.RESTClient) *Folde
result = client.Get().AbsPath("api", "folders", hit.UID). result = client.Get().AbsPath("api", "folders", hit.UID).
Do(context.Background()). Do(context.Background()).
StatusCode(&statusCode) StatusCode(&statusCode)
require.NoError(t, result.Error(), "getting folder access info (/api)") assert.NoError(t, result.Error(), "getting folder access info (/api) uid:%s", hit.UID)
require.Equal(t, int(http.StatusOK), statusCode) require.Equal(t, int(http.StatusOK), statusCode)
body, err := result.Raw() body, err := result.Raw()
@@ -394,7 +395,7 @@ func makeRoot(lookup map[string]*FolderView, name string) *FolderView {
shared := &FolderView{} // when not found shared := &FolderView{} // when not found
root := &FolderView{} root := &FolderView{}
for _, v := range lookup { for _, v := range lookup {
if v.Parent == "" { if folder.IsRootFolder(v.Parent) { // general or empty
root.Children = append(root.Children, v) root.Children = append(root.Children, v)
} else { } else {
p, ok := lookup[v.Parent] p, ok := lookup[v.Parent]
@@ -445,7 +446,7 @@ func getFoldersFromDashboardV0Search(t *testing.T, client *rest.RESTClient, ns s
folderV1.APIVersion, "namespaces", ns, "folders", hit.Name, "access"). folderV1.APIVersion, "namespaces", ns, "folders", hit.Name, "access").
Do(context.Background()). Do(context.Background()).
StatusCode(&statusCode) StatusCode(&statusCode)
require.NoError(t, result.Error(), "getting folder access info (/access)") require.NoError(t, result.Error(), "getting folder access info (/access) name:%s", hit.Name)
require.Equal(t, int(http.StatusOK), statusCode) require.Equal(t, int(http.StatusOK), statusCode)
body, err := result.Raw() body, err := result.Raw()

View File

@@ -134,9 +134,13 @@ func TestIntegrationFoldersApp(t *testing.T) {
}) })
// test on all dual writer modes // test on all dual writer modes
for mode := 0; mode <= 4; mode++ { modes := []grafanarest.DualWriterMode{
modeDw := grafanarest.DualWriterMode(mode) grafanarest.Mode0, // legacy only
grafanarest.Mode2, // write both, read legacy
grafanarest.Mode3, // write both, read unified
grafanarest.Mode4,
}
for _, modeDw := range modes {
t.Run(fmt.Sprintf("with dual write (unified storage, mode %v)", modeDw), func(t *testing.T) { t.Run(fmt.Sprintf("with dual write (unified storage, mode %v)", modeDw), func(t *testing.T) {
doFolderTests(t, apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{ doFolderTests(t, apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{
DisableDataMigrations: true, DisableDataMigrations: true,
@@ -1257,11 +1261,13 @@ func TestIntegrationFoldersGetAPIEndpointK8S(t *testing.T) {
requestToAnotherOrg: true, requestToAnotherOrg: true,
}, },
} }
for _, modeDw := range []grafanarest.DualWriterMode{
for mode := 0; mode <= 4; mode++ { grafanarest.Mode0, // legacy only
t.Run(fmt.Sprintf("Mode_%d", mode), func(t *testing.T) { grafanarest.Mode2, // write both, read legacy
modeDw := grafanarest.DualWriterMode(mode) grafanarest.Mode3, // write both, read unified
grafanarest.Mode4,
} {
t.Run(fmt.Sprintf("Mode_%d", modeDw), func(t *testing.T) {
helper := apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{ helper := apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{
DisableDataMigrations: true, DisableDataMigrations: true,
AppModeProduction: true, AppModeProduction: true,