K8s: Library Panels: Add rerouting for get (#107362)

This commit is contained in:
Stephanie Hingtgen
2025-06-30 13:26:24 -05:00
committed by GitHub
parent 6cc26233fd
commit b1d3155b60
28 changed files with 1152 additions and 196 deletions
+227 -5
View File
@@ -1,23 +1,38 @@
package libraryelements
import (
"encoding/json"
"errors"
"fmt"
"hash/fnv"
"net/http"
"strings"
dashboardV0 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v0alpha1"
"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/utils"
"github.com/grafana/grafana/pkg/infra/metrics"
"github.com/grafana/grafana/pkg/kinds/librarypanel"
ac "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/featuremgmt"
"github.com/grafana/grafana/pkg/services/folder"
"github.com/grafana/grafana/pkg/services/libraryelements/model"
"github.com/grafana/grafana/pkg/services/org"
"github.com/grafana/grafana/pkg/services/user"
"github.com/grafana/grafana/pkg/setting"
"github.com/grafana/grafana/pkg/util/errhttp"
"github.com/grafana/grafana/pkg/web"
k8serrors "k8s.io/apimachinery/pkg/api/errors"
v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/client-go/dynamic"
)
func (l *LibraryElementService) registerAPIEndpoints() {
@@ -25,13 +40,13 @@ func (l *LibraryElementService) registerAPIEndpoints() {
l.RouteRegister.Group("/api/library-elements", func(entities routing.RouteRegister) {
uidScope := ScopeLibraryPanelsProvider.GetResourceScopeUID(ac.Parameter(":uid"))
entities.Post("/", authorize(ac.EvalPermission(ActionLibraryPanelsCreate)), routing.Wrap(l.createHandler))
entities.Delete("/:uid", authorize(ac.EvalPermission(ActionLibraryPanelsDelete, uidScope)), routing.Wrap(l.deleteHandler))
entities.Get("/", authorize(ac.EvalPermission(ActionLibraryPanelsRead)), routing.Wrap(l.getAllHandler))
entities.Post("/", authorize(ac.EvalPermission(ActionLibraryPanelsCreate)), routing.Wrap(l.createHandler)) // TODO: add wrapper for k8s
entities.Delete("/:uid", authorize(ac.EvalPermission(ActionLibraryPanelsDelete, uidScope)), routing.Wrap(l.deleteHandler)) // TODO: add wrapper for k8s
entities.Get("/", authorize(ac.EvalPermission(ActionLibraryPanelsRead)), routing.Wrap(l.getAllHandler)) // TODO: add wrapper for k8s - requires search
entities.Get("/:uid", authorize(ac.EvalPermission(ActionLibraryPanelsRead)), routing.Wrap(l.getHandler))
entities.Get("/:uid/connections/", authorize(ac.EvalPermission(ActionLibraryPanelsRead, uidScope)), routing.Wrap(l.getConnectionsHandler))
entities.Get("/name/:name", routing.Wrap(l.getByNameHandler))
entities.Patch("/:uid", authorize(ac.EvalPermission(ActionLibraryPanelsWrite, uidScope)), routing.Wrap(l.patchHandler))
entities.Get("/name/:name", routing.Wrap(l.getByNameHandler)) // TODO: add wrapper for k8s - requires search
entities.Patch("/:uid", authorize(ac.EvalPermission(ActionLibraryPanelsWrite, uidScope)), routing.Wrap(l.patchHandler)) // TODO: add wrapper for k8s
})
}
@@ -133,6 +148,11 @@ func (l *LibraryElementService) deleteHandler(c *contextmodel.ReqContext) respon
// 404: notFoundError
// 500: internalServerError
func (l *LibraryElementService) getHandler(c *contextmodel.ReqContext) response.Response {
if l.features.IsEnabled(c.Req.Context(), featuremgmt.FlagKubernetesLibraryPanels) {
l.k8sHandler.getK8sLibraryElement(c)
return nil // already handled in the k8s handler
}
ctx := c.Req.Context()
element, err := l.getLibraryElementByUid(ctx, c.SignedInUser,
model.GetLibraryElementCommand{
@@ -529,3 +549,205 @@ type GetLibraryElementConnectionsResponse struct {
// in: body
Body model.LibraryElementConnectionsResponse `json:"body"`
}
//-----------------------------------------------------------------------------------------
// Library Elements k8s wrapper functions
//-----------------------------------------------------------------------------------------
type libraryElementsK8sHandler struct {
cfg *setting.Cfg
namespacer request.NamespaceMapper
gvr schema.GroupVersionResource
clientConfigProvider grafanaapiserver.DirectRestConfigProvider
folderService folder.Service
dashboardsService dashboards.DashboardService
userService user.Service
}
func newLibraryElementsK8sHandler(cfg *setting.Cfg, clientConfigProvider grafanaapiserver.DirectRestConfigProvider, folderService folder.Service, userService user.Service, dashboardsService dashboards.DashboardService) *libraryElementsK8sHandler {
gvr := schema.GroupVersionResource{
Group: dashboardV0.APIGroup,
Version: dashboardV0.APIVersion,
Resource: dashboardV0.LIBRARY_PANEL_RESOURCE,
}
return &libraryElementsK8sHandler{
cfg: cfg,
gvr: gvr,
namespacer: request.GetNamespaceMapper(cfg),
clientConfigProvider: clientConfigProvider,
folderService: folderService,
dashboardsService: dashboardsService,
userService: userService,
}
}
func (lk8s *libraryElementsK8sHandler) getK8sLibraryElement(c *contextmodel.ReqContext) {
client, ok := lk8s.getClient(c)
if !ok {
return
}
uid := web.Params(c.Req)[":uid"]
out, err := client.Get(c.Req.Context(), uid, v1.GetOptions{})
if err != nil {
lk8s.writeError(c, err)
return
}
dto, err := lk8s.unstructuredToLegacyLibraryPanelDTO(c, *out)
if err != nil {
c.JsonApiErr(http.StatusInternalServerError, "conversion error", err)
return
}
c.JSON(http.StatusOK, model.LibraryElementResponse{Result: *dto})
}
func (lk8s *libraryElementsK8sHandler) unstructuredToLegacyLibraryPanelDTO(c *contextmodel.ReqContext, item unstructured.Unstructured) (*model.LibraryElementDTO, error) {
spec, exists := item.Object["spec"].(map[string]interface{})
if !exists {
return nil, fmt.Errorf("spec not found in unstructured object")
}
id := int64(0)
folderUID := ""
meta, err := utils.MetaAccessor(&item)
if err == nil {
id = meta.GetDeprecatedInternalID() // nolint:staticcheck
folderUID = meta.GetFolder()
}
var libraryPanelSpec dashboardV0.LibraryPanelSpec
specJSON, err := json.Marshal(spec)
if err != nil {
return nil, fmt.Errorf("failed to marshal spec: %w", err)
}
err = json.Unmarshal(specJSON, &libraryPanelSpec)
if err != nil {
return nil, fmt.Errorf("failed to unmarshal spec into LibraryPanelSpec: %w", err)
}
// need to reconstruct this section from what we have in the k8s object
legacyModel := map[string]any{}
legacyModel["datasource"] = libraryPanelSpec.Datasource
legacyModel["description"] = libraryPanelSpec.Description
legacyModel["fieldConfig"] = libraryPanelSpec.FieldConfig.Object
legacyModel["gridPos"] = libraryPanelSpec.GridPos
legacyModel["id"] = id
legacyModel["options"] = libraryPanelSpec.Options.Object
legacyModel["pluginVersion"] = libraryPanelSpec.PluginVersion
legacyModel["type"] = libraryPanelSpec.Type
legacyModel["title"] = libraryPanelSpec.PanelTitle // this is the title of the panel when displayed in the dashboard
legacyModel["libraryPanel"] = map[string]string{
"name": libraryPanelSpec.Title, // this is the title of the actual library panel, when displayed in the library panel list
"uid": item.GetName(),
}
if len(libraryPanelSpec.Links) > 0 {
legacyModel["links"] = libraryPanelSpec.Links
}
if len(libraryPanelSpec.Targets) > 0 {
legacyModel["targets"] = libraryPanelSpec.Targets
}
if libraryPanelSpec.Transparent {
legacyModel["transparent"] = libraryPanelSpec.Transparent
}
finalModel, err := json.Marshal(legacyModel)
if err != nil {
return nil, fmt.Errorf("failed to marshal model: %w", err)
}
dto := &model.LibraryElementDTO{
ID: id,
OrgID: c.OrgID,
FolderUID: folderUID,
UID: item.GetName(),
Name: libraryPanelSpec.Title,
Kind: int64(model.PanelElement),
Type: libraryPanelSpec.Type,
Description: libraryPanelSpec.Description,
Model: finalModel,
Version: item.GetGeneration(),
Meta: model.LibraryElementDTOMeta{
FolderUID: folderUID,
Created: meta.GetCreationTimestamp().Time,
},
}
if folderUID != "" {
folder, err := lk8s.folderService.Get(c.Req.Context(), &folder.GetFolderQuery{
OrgID: c.OrgID,
UID: &folderUID,
SignedInUser: c.SignedInUser,
})
if err != nil {
return nil, err
}
dto.Meta.FolderName = folder.Title
dto.FolderID = folder.ID // nolint:staticcheck
}
dashboards, err := lk8s.dashboardsService.GetDashboardsByLibraryPanelUID(c.Req.Context(), item.GetName(), c.OrgID)
if err != nil {
return nil, err
}
dto.Meta.ConnectedDashboards = int64(len(dashboards))
createdBy := meta.GetCreatedBy()
updatedBy := createdBy // the old /api returns the same user for updated if it was never updated
userUIDs := []string{meta.GetCreatedBy()}
if timestamp, err := meta.GetUpdatedTimestamp(); err == nil && timestamp != nil {
dto.Meta.Updated = *timestamp
updatedBy = meta.GetUpdatedBy()
userUIDs = append(userUIDs, updatedBy)
} else {
// if never updated, the old /api returns the same timestamp for updated as for created
dto.Meta.Updated = dto.Meta.Created
}
users, err := lk8s.userService.ListByIdOrUID(c.Req.Context(), userUIDs, []int64{c.OrgID})
if err != nil {
return nil, err
}
for _, user := range users {
if user.UID == createdBy {
dto.Meta.CreatedBy = librarypanel.LibraryElementDTOMetaUser{
Id: user.ID,
Name: user.Login,
AvatarUrl: dtos.GetGravatarUrl(lk8s.cfg, user.Email),
}
}
// not else because /api returns the same user for updated if it was never updated
if user.UID == updatedBy {
dto.Meta.UpdatedBy = librarypanel.LibraryElementDTOMetaUser{
Id: user.ID,
Name: user.Login,
AvatarUrl: dtos.GetGravatarUrl(lk8s.cfg, user.Email),
}
}
}
return dto, nil
}
//-----------------------------------------------------------------------------------------
// Utility functions
//-----------------------------------------------------------------------------------------
func (lk8s *libraryElementsK8sHandler) getClient(c *contextmodel.ReqContext) (dynamic.ResourceInterface, bool) {
dyn, err := dynamic.NewForConfig(lk8s.clientConfigProvider.GetDirectRestConfig(c))
if err != nil {
c.JsonApiErr(500, "client", err)
return nil, false
}
return dyn.Resource(lk8s.gvr).Namespace(lk8s.namespacer(c.OrgID)), true
}
func (lk8s *libraryElementsK8sHandler) 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)
}
@@ -0,0 +1,180 @@
package libraryelements
import (
"context"
"encoding/json"
"net/http/httptest"
"testing"
"time"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"github.com/grafana/grafana/pkg/api/dtos"
"github.com/grafana/grafana/pkg/apimachinery/utils"
contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model"
"github.com/grafana/grafana/pkg/services/dashboards"
"github.com/grafana/grafana/pkg/services/folder"
"github.com/grafana/grafana/pkg/services/folder/foldertest"
"github.com/grafana/grafana/pkg/services/libraryelements/model"
"github.com/grafana/grafana/pkg/services/org"
"github.com/grafana/grafana/pkg/services/user"
"github.com/grafana/grafana/pkg/services/user/usertest"
"github.com/grafana/grafana/pkg/setting"
"github.com/grafana/grafana/pkg/web"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)
func TestUnstructuredToLegacyLibraryPanelDTO(t *testing.T) {
cfg := setting.NewCfg()
userSvc := &usertest.FakeUserService{}
testUser := &user.User{
ID: 1,
UID: "test-user-uid",
Login: "testuser",
Email: "test@example.com",
}
userSvc.ExpectedListUsersByIdOrUid = []*user.User{testUser}
testFolder := &folder.Folder{
ID: 1,
UID: "test-folder-uid",
Title: "Test Folder",
}
folderSvc := &foldertest.FakeService{
ExpectedFolder: testFolder,
}
dashboardsSvc := &dashboards.FakeDashboardService{}
testDashboard := &dashboards.DashboardRef{
ID: 1,
UID: "test-dashboard-uid",
FolderUID: testFolder.UID,
}
dashboardsSvc.On("GetDashboardsByLibraryPanelUID", mock.Anything, "test-panel-uid", int64(1)).Return([]*dashboards.DashboardRef{testDashboard}, nil)
handler := &libraryElementsK8sHandler{
cfg: cfg,
folderService: folderSvc,
dashboardsService: dashboardsSvc,
userService: userSvc,
}
unstructuredObj := &unstructured.Unstructured{
Object: map[string]interface{}{
"apiVersion": "dashboard.grafana.app/v0alpha1",
"kind": "LibraryPanel",
"metadata": map[string]any{
"name": "test-panel-uid",
},
"spec": map[string]any{
"type": "text",
"pluginVersion": "1.0.0",
"title": "Test Library Panel",
"panelTitle": "Test Panel Title",
"description": "Test description",
"options": map[string]interface{}{
"content": "Test content",
},
"fieldConfig": map[string]interface{}{
"defaults": map[string]interface{}{
"color": map[string]interface{}{
"mode": "palette-classic",
},
},
},
"gridPos": map[string]interface{}{
"h": 8,
"w": 12,
"x": 0,
"y": 0,
},
"datasource": map[string]interface{}{
"type": "testdata",
"uid": "test-datasource",
},
"transparent": true,
"links": []interface{}{
map[string]interface{}{
"title": "Test Link",
"url": "https://example.com",
},
},
"targets": []interface{}{
map[string]interface{}{
"refId": "A",
"expr": "test_query",
},
},
},
},
}
meta, err := utils.MetaAccessor(unstructuredObj)
require.NoError(t, err)
meta.SetFolder(testFolder.UID)
meta.SetGeneration(2)
creationTimestamp := metav1.NewTime(time.Now())
meta.SetCreationTimestamp(creationTimestamp)
meta.SetCreatedBy(testUser.UID)
meta.SetDeprecatedInternalID(123) // nolint:staticcheck
reqContext := &contextmodel.ReqContext{
Context: &web.Context{
Req: httptest.NewRequest("GET", "/", nil).WithContext(context.Background()),
},
SignedInUser: &user.SignedInUser{
UserID: 1,
OrgID: 1,
OrgRole: org.RoleAdmin,
},
}
result, err := handler.unstructuredToLegacyLibraryPanelDTO(reqContext, *unstructuredObj)
require.NoError(t, err)
require.NotNil(t, result)
require.Equal(t, int64(123), result.ID)
require.Equal(t, int64(1), result.OrgID)
require.Equal(t, testFolder.UID, result.FolderUID)
require.Equal(t, "test-panel-uid", result.UID)
require.Equal(t, "Test Library Panel", result.Name)
require.Equal(t, int64(model.PanelElement), result.Kind)
require.Equal(t, "text", result.Type)
require.Equal(t, "Test description", result.Description)
require.Equal(t, int64(2), result.Version)
require.Equal(t, testFolder.UID, result.Meta.FolderUID)
require.Equal(t, testFolder.Title, result.Meta.FolderName)
require.Equal(t, int64(1), result.Meta.ConnectedDashboards)
require.Equal(t, int64(1), result.FolderID) // nolint:staticcheck
require.Equal(t, creationTimestamp.Format(time.RFC3339), result.Meta.Created.Format(time.RFC3339))
require.Equal(t, testUser.ID, result.Meta.CreatedBy.Id)
require.Equal(t, testUser.Login, result.Meta.CreatedBy.Name)
require.Equal(t, dtos.GetGravatarUrl(cfg, testUser.Email), result.Meta.CreatedBy.AvatarUrl)
require.Equal(t, creationTimestamp.Format(time.RFC3339), result.Meta.Updated.Format(time.RFC3339))
require.Equal(t, testUser.ID, result.Meta.UpdatedBy.Id)
require.Equal(t, testUser.Login, result.Meta.UpdatedBy.Name)
require.Equal(t, dtos.GetGravatarUrl(cfg, testUser.Email), result.Meta.UpdatedBy.AvatarUrl)
var modelMap map[string]interface{}
err = json.Unmarshal(result.Model, &modelMap)
require.NoError(t, err)
require.Equal(t, "testdata", modelMap["datasource"].(map[string]interface{})["type"])
require.Equal(t, "test-datasource", modelMap["datasource"].(map[string]interface{})["uid"])
require.Equal(t, "Test description", modelMap["description"])
require.Equal(t, float64(123), modelMap["id"])
require.Equal(t, "text", modelMap["type"])
require.Equal(t, "Test Panel Title", modelMap["title"])
require.Equal(t, "Test content", modelMap["options"].(map[string]interface{})["content"])
require.Equal(t, true, modelMap["transparent"])
require.Equal(t, "Test Library Panel", modelMap["libraryPanel"].(map[string]interface{})["name"])
require.Equal(t, "test-panel-uid", modelMap["libraryPanel"].(map[string]interface{})["uid"])
links := modelMap["links"].([]interface{})
require.Len(t, links, 1)
require.Equal(t, "Test Link", links[0].(map[string]interface{})["title"])
targets := modelMap["targets"].([]interface{})
require.Len(t, targets, 1)
require.Equal(t, "A", targets[0].(map[string]interface{})["refId"])
dashboardsSvc.AssertExpectations(t)
}
@@ -9,14 +9,16 @@ import (
"github.com/grafana/grafana/pkg/infra/db"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/services/accesscontrol"
grafanaapiserver "github.com/grafana/grafana/pkg/services/apiserver"
"github.com/grafana/grafana/pkg/services/dashboards"
"github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/services/folder"
"github.com/grafana/grafana/pkg/services/libraryelements/model"
"github.com/grafana/grafana/pkg/services/user"
"github.com/grafana/grafana/pkg/setting"
)
func ProvideService(cfg *setting.Cfg, sqlStore db.DB, routeRegister routing.RouteRegister, folderService folder.Service, features featuremgmt.FeatureToggles, ac accesscontrol.AccessControl, dashboardsService dashboards.DashboardService) *LibraryElementService {
func ProvideService(cfg *setting.Cfg, sqlStore db.DB, routeRegister routing.RouteRegister, folderService folder.Service, features featuremgmt.FeatureToggles, ac accesscontrol.AccessControl, dashboardsService dashboards.DashboardService, clientConfigProvider grafanaapiserver.DirectRestConfigProvider, userService user.Service) *LibraryElementService {
l := &LibraryElementService{
Cfg: cfg,
SQLStore: sqlStore,
@@ -26,6 +28,7 @@ func ProvideService(cfg *setting.Cfg, sqlStore db.DB, routeRegister routing.Rout
log: log.New("library-elements"),
features: features,
AccessControl: ac,
k8sHandler: newLibraryElementsK8sHandler(cfg, clientConfigProvider, folderService, userService, dashboardsService),
}
l.registerAPIEndpoints()
@@ -55,6 +58,7 @@ type LibraryElementService struct {
log log.Logger
features featuremgmt.FeatureToggles
AccessControl accesscontrol.AccessControl
k8sHandler *libraryElementsK8sHandler
}
var _ Service = (*LibraryElementService)(nil)