fix: library panel folder uid conversion (#127235)

This commit is contained in:
Mustafa Sencer Özcan
2026-06-25 15:02:18 +02:00
committed by GitHub
parent 8a3435c121
commit c162e3cbc9
6 changed files with 185 additions and 17 deletions
+2
View File
@@ -1001,6 +1001,7 @@ github.com/google/renameio v0.1.0 h1:GOZbcHa3HfsPKPlmyPyN2KEohoMXOhdMbHrvbpl2QaA
github.com/google/renameio/v2 v2.0.2 h1:qKZs+tfn+arruZZhQ7TKC/ergJunuJicWS6gLDt/dGw=
github.com/google/renameio/v2 v2.0.2/go.mod h1:OX+G6WHHpHq3NVj7cAOleLOwJfcQ1s3uUJQCrr78SWo=
github.com/google/s2a-go v0.1.7/go.mod h1:50CgR4k1jNlWBu4UfS4AcfhVe1r6pdZPygJ3R8F0Qdw=
github.com/google/safetext v0.0.0-20260330151545-1fb717a317c5/go.mod h1:H3K1Iu/utuCfa10JO+GsmKUYSWi7ug57Rk6GaDRHaaQ=
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4=
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ=
github.com/google/uuid v1.2.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
@@ -1456,6 +1457,7 @@ github.com/pbnjay/memory v0.0.0-20210728143218-7b4eea64cf58 h1:onHthvaw9LFnH4t2D
github.com/pbnjay/memory v0.0.0-20210728143218-7b4eea64cf58/go.mod h1:DXv8WO4yhMYhSNPKjeNKa5WY9YCIEBRbNzFFPJbWO6Y=
github.com/pborman/getopt v0.0.0-20170112200414-7148bc3a4c30 h1:BHT1/DKsYDGkUgQ2jmMaozVcdk+sVfz0+1ZJq4zkWgw=
github.com/pborman/uuid v1.2.0 h1:J7Q5mO4ysT1dv8hyrUGHb9+ooztCXu1D8MY8DZYsu3g=
github.com/pborman/uuid v1.2.1/go.mod h1:X/NO0urCmaxf9VXbdlT7C2Yzkj2IKimNn4k+gtPdI/k=
github.com/pelletier/go-toml/v2 v2.0.9/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdUvyaeZurnPPDc=
github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc=
github.com/perimeterx/marshmallow v1.1.5 h1:a2LALqQ1BlHM8PZblsDdidgv1mWi1DgC2UmX50IvK2s=
+15
View File
@@ -798,6 +798,21 @@ func (b *DashboardsAPIBuilder) UpdateAPIGroupInfo(apiGroupInfo *genericapiserver
opts.StorageOptsRegister(dashv0.DashboardResourceInfo.GroupResource(), storageOpts)
// Library panels live inside folders, so the unified storage backend must accept the
// grafana.app/folder annotation. They are keyed by their own GroupResource, so they need
// a separate registration from dashboards; without it they default to
// EnableFolderSupport=false and any folder-scoped write (e.g. provisioning syncing a panel
// into a managed folder) is rejected with "folders are not supported". The folder is
// optional (panels may live at the root), so RequireFolder stays false.
//nolint:staticcheck // not yet migrated to OpenFeature
if b.libraryPanelsEnabled {
opts.StorageOptsRegister(dashv0.LibraryPanelResourceInfo.GroupResource(), apistore.StorageOptions{
Scheme: opts.Scheme,
Index: b.unified,
EnableFolderSupport: true,
})
}
// v0alpha1
if err := b.storageForVersion(apiGroupInfo, opts,
dashv0.DashboardResourceInfo,
@@ -45,6 +45,9 @@ func ToPatchLibraryElementCommand(raw runtime.Object) (*model.PatchLibraryElemen
FolderUID: &folder,
Kind: 1, // the only kind... LibraryPanel
Name: obj.FindTitle("library panel"),
// generation mirrors the legacy library element version, so it carries the
// optimistic-concurrency token through the k8s update path.
Version: obj.GetGeneration(),
}
cmd.Model, err = toRawMessage(raw)
return cmd, err
@@ -28,6 +28,9 @@ func TestConversionsCommands(t *testing.T) {
input: &v0alpha1.LibraryPanel{
ObjectMeta: metav1.ObjectMeta{
Name: "uid",
// generation mirrors the legacy library element version and must flow
// into the patch command's Version for optimistic concurrency.
Generation: 3,
Annotations: map[string]string{
utils.AnnoKeyFolder: "aaa",
},
@@ -76,7 +79,7 @@ func TestConversionsCommands(t *testing.T) {
UID: "uid",
Name: "title",
Kind: 1,
Version: 0,
Version: 3,
Model: json.RawMessage(`{"type":"timeseries","pluginVersion":"1.2.3","title":"title","panelTitle":"panel title","description":"descr","options":{"hello":"options"},"fieldConfig":{"hello":"fieldConfig"},"datasource":{"type":"ttt","uid":"uid","apiVersion":"v0alpha1"},"gridPos":{"w":1,"h":2,"x":3,"y":4},"transparent":true,"links":[{"link1":"hello"}]}`),
},
},
+34 -16
View File
@@ -125,16 +125,23 @@ func (l *LibraryElementService) CreateElement(c context.Context, signedInUser id
}
if cmd.FolderUID != nil && l.folderService != nil {
f, err := l.folderService.Get(c, &folder.GetFolderQuery{
OrgID: signedInUser.GetOrgID(),
UID: cmd.FolderUID,
SignedInUser: signedInUser,
})
if err != nil {
return model.LibraryElementDTO{}, err
}
if f.ManagedBy == utils.ManagerKindRepo && !identity.IsProvisioningServiceIdentity(signedInUser) {
return model.LibraryElementDTO{}, model.ErrLibraryElementProvisionedFolder
if *cmd.FolderUID == "" {
cmd.FolderID = 0 // nolint:staticcheck // general/root folder
} else {
f, err := l.folderService.Get(c, &folder.GetFolderQuery{
OrgID: signedInUser.GetOrgID(),
UID: cmd.FolderUID,
SignedInUser: signedInUser,
})
if err != nil {
return model.LibraryElementDTO{}, err
}
if f.ManagedBy == utils.ManagerKindRepo && !identity.IsProvisioningServiceIdentity(signedInUser) {
return model.LibraryElementDTO{}, model.ErrLibraryElementProvisionedFolder
}
// The k8s write path supplies only folder_uid. Align the legacy folder_id so
// folder_id-based reads don't misplace the panel in the general folder.
cmd.FolderID = f.ID // nolint:staticcheck
}
}
@@ -616,6 +623,13 @@ func (l *LibraryElementService) PatchLibraryElement(c context.Context, signedInU
if f.ManagedBy == utils.ManagerKindRepo && !identity.IsProvisioningServiceIdentity(signedInUser) {
return model.LibraryElementDTO{}, model.ErrLibraryElementProvisionedFolder
}
// The k8s write path supplies only folder_uid. Align the legacy folder_id so
// handleFolderIDPatches doesn't treat the panel as moving to the general folder.
if *cmd.FolderUID == "" {
cmd.FolderID = 0 // nolint:staticcheck
} else {
cmd.FolderID = f.ID // nolint:staticcheck
}
// The destination folder must allow the caller to create library
// panels there. The route-level authorize guard only checks
@@ -700,14 +714,18 @@ func (l *LibraryElementService) PatchLibraryElement(c context.Context, signedInU
if err := l.handleFolderIDPatches(c, &libraryElement, elementInDB.FolderID, cmd.FolderID, signedInUser); err != nil {
return err
}
// Keep folder_uid in sync with folder_id: getAllLibraryElements reads folder_uid
// directly from the table, so leaving it stale causes the list view to diverge.
// FolderID is authoritative here (set by handleFolderIDPatches); derive FolderUID
// from it so PATCHes that don't touch the folder still heal any prior drift.
// Keep folder_uid in sync: getAllLibraryElements reads folder_uid directly from
// the table, so leaving it stale causes the list view to diverge. folder_uid is
// the durable identifier (folder_id may be 0 when folders live in unified
// storage), so when the caller provided a folder_uid treat it as authoritative;
// otherwise derive it from the resolved folder_id to heal any prior drift.
folderID := libraryElement.FolderID // nolint:staticcheck
if folderID == 0 {
switch {
case cmd.FolderUID != nil:
libraryElement.FolderUID = *cmd.FolderUID
case folderID == 0:
libraryElement.FolderUID = ""
} else {
default:
f, err := l.folderService.Get(c, &folder.GetFolderQuery{
OrgID: signedInUser.GetOrgID(),
ID: &folderID,
@@ -0,0 +1,127 @@
package libraryelements
import (
"testing"
"github.com/stretchr/testify/require"
"github.com/grafana/grafana/pkg/infra/db"
"github.com/grafana/grafana/pkg/services/folder"
"github.com/grafana/grafana/pkg/services/libraryelements/model"
)
type storedFolder struct {
FolderID int64 `xorm:"folder_id"`
FolderUID string `xorm:"folder_uid"`
}
func readStoredFolder(t *testing.T, sc scenarioContext, uid string) storedFolder {
t.Helper()
var row storedFolder
err := sc.sqlStore.WithDbSession(sc.reqContext.Req.Context(), func(session *db.Session) error {
found, err := session.SQL("SELECT folder_id, folder_uid FROM library_element WHERE uid = ?", uid).Get(&row)
require.NoError(t, err)
require.True(t, found, "library_element row not found for uid %s", uid)
return nil
})
require.NoError(t, err)
return row
}
func createK8sPanel(t *testing.T, sc scenarioContext, uid, folderUID string) {
t.Helper()
fuid := folderUID
_, err := sc.service.CreateElement(sc.reqContext.Req.Context(), &sc.user, model.CreateLibraryElementCommand{
UID: uid,
FolderUID: &fuid,
Kind: int64(model.PanelElement),
Name: "k8s panel",
Model: []byte(`{"type":"timeseries","title":"k8s panel"}`),
// FolderID intentionally left 0, as the k8s conversion does.
})
require.NoError(t, err)
}
func TestIntegration_K8sWritePath_FolderConsistency(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test")
}
t.Run("create resolves the legacy folder_id from folder_uid", func(t *testing.T) {
sc := setupTestScenario(t)
const folderUID = "uid_for_target"
sc.folderSvc.ExpectedFolder = &folder.Folder{ID: 42, OrgID: sc.user.OrgID, UID: folderUID, Title: "target"}
fuid := folderUID
dto, err := sc.service.CreateElement(sc.reqContext.Req.Context(), &sc.user, model.CreateLibraryElementCommand{
UID: "k8s-create",
FolderUID: &fuid,
Kind: int64(model.PanelElement),
Name: "k8s panel",
Model: []byte(`{"type":"timeseries","title":"k8s panel"}`),
})
require.NoError(t, err)
require.Equal(t, int64(42), dto.FolderID) //nolint:staticcheck
row := readStoredFolder(t, sc, "k8s-create")
require.Equal(t, int64(42), row.FolderID, "folder_id must be resolved from folder_uid")
require.Equal(t, folderUID, row.FolderUID)
})
t.Run("create at the root keeps folder_id zero", func(t *testing.T) {
sc := setupTestScenario(t)
createK8sPanel(t, sc, "k8s-root", "")
row := readStoredFolder(t, sc, "k8s-root")
require.Equal(t, int64(0), row.FolderID)
require.Equal(t, "", row.FolderUID)
})
t.Run("patch with only folder_uid keeps the panel in its folder", func(t *testing.T) {
sc := setupTestScenario(t)
const folderUID = "uid_for_target"
sc.folderSvc.ExpectedFolder = &folder.Folder{ID: 42, OrgID: sc.user.OrgID, UID: folderUID, Title: "target"}
createK8sPanel(t, sc, "k8s-patch", folderUID)
// Mirror the k8s update path: only folder_uid set, folder_id unset, version
// carried via the object generation (1 after create).
fuid := folderUID
_, err := sc.service.PatchLibraryElement(sc.reqContext.Req.Context(), &sc.user, model.PatchLibraryElementCommand{
UID: "k8s-patch",
FolderUID: &fuid,
Kind: int64(model.PanelElement),
Name: "k8s panel edited",
Version: 1,
Model: []byte(`{"type":"timeseries","title":"k8s panel edited"}`),
}, "k8s-patch")
require.NoError(t, err)
row := readStoredFolder(t, sc, "k8s-patch")
require.Equal(t, int64(42), row.FolderID, "folder_id must survive a folder_uid-only patch")
require.Equal(t, folderUID, row.FolderUID, "folder_uid must not be wiped on patch")
})
t.Run("patch to a different folder updates both columns", func(t *testing.T) {
sc := setupTestScenario(t)
const srcUID, dstUID = "uid_src", "uid_dst"
sc.folderSvc.ExpectedFolder = &folder.Folder{ID: 42, OrgID: sc.user.OrgID, UID: srcUID, Title: "src"}
createK8sPanel(t, sc, "k8s-move", srcUID)
// Move to the destination folder.
sc.folderSvc.ExpectedFolder = &folder.Folder{ID: 99, OrgID: sc.user.OrgID, UID: dstUID, Title: "dst"}
fuid := dstUID
_, err := sc.service.PatchLibraryElement(sc.reqContext.Req.Context(), &sc.user, model.PatchLibraryElementCommand{
UID: "k8s-move",
FolderUID: &fuid,
Kind: int64(model.PanelElement),
Name: "k8s panel",
Version: 1,
Model: []byte(`{"type":"timeseries","title":"k8s panel"}`),
}, "k8s-move")
require.NoError(t, err)
row := readStoredFolder(t, sc, "k8s-move")
require.Equal(t, int64(99), row.FolderID)
require.Equal(t, dstUID, row.FolderUID)
})
}