mirror of
https://github.com/grafana/grafana.git
synced 2026-08-19 01:34:54 -05:00
fix: library panel folder uid conversion (#127235)
This commit is contained in:
@@ -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=
|
||||
|
||||
@@ -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"}]}`),
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user