LibraryPanels: Support CRUD via apiserver (#113035)

This commit is contained in:
Ryan McKinley
2025-10-31 08:14:38 +03:00
committed by GitHub
parent cf242def3a
commit 53aed34652
22 changed files with 617 additions and 159 deletions
@@ -0,0 +1,91 @@
package v0alpha1
import (
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
data "github.com/grafana/grafana-plugin-sdk-go/experimental/apis/data/v0alpha1"
common "github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1"
)
// +k8s:deepcopy-gen=true
// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object
type LibraryPanel struct {
metav1.TypeMeta `json:",inline"`
// Standard object's metadata
// More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#metadata
// +optional
metav1.ObjectMeta `json:"metadata,omitempty"`
// Panel properties
Spec LibraryPanelSpec `json:"spec"`
// Status will show errors
Status *LibraryPanelStatus `json:"status,omitempty"`
}
// +k8s:deepcopy-gen=true
// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object
type LibraryPanelList struct {
metav1.TypeMeta `json:",inline"`
// +optional
metav1.ListMeta `json:"metadata,omitempty"`
Items []LibraryPanel `json:"items"`
}
// +k8s:deepcopy-gen=true
type LibraryPanelSpec struct {
// The panel type
Type string `json:"type"`
// The panel type
PluginVersion string `json:"pluginVersion,omitempty"`
// The title of the library panel
Title string `json:"title,omitempty"`
// The title of the panel when displayed in the dashboard
PanelTitle string `json:"panelTitle,omitempty"`
// Library panel description
Description string `json:"description,omitempty"`
// The options schema depends on the panel type
Options common.Unstructured `json:"options"`
// The fieldConfig schema depends on the panel type
FieldConfig common.Unstructured `json:"fieldConfig"`
// The default datasource type
Datasource *data.DataSourceRef `json:"datasource,omitempty"`
// The grid position
GridPos GridPos `json:"gridPos,omitempty"`
// Whether the panel is transparent
Transparent bool `json:"transparent,omitempty"`
// The links for the panel
Links []common.Unstructured `json:"links,omitempty"`
// The datasource queries
// +listType=atomic
Targets []data.DataQuery `json:"targets,omitempty"`
}
// +k8s:deepcopy-gen=true
type GridPos struct {
W int `json:"w"`
H int `json:"h"`
X int `json:"x"`
Y int `json:"y"`
}
// +k8s:deepcopy-gen=true
type LibraryPanelStatus struct {
// Translation warnings (mostly things that were in SQL columns but not found in the saved body)
Warnings []string `json:"warnings,omitempty"`
// The properties previously stored in SQL that are not included in this model
Missing common.Unstructured `json:"missing,omitempty"`
}
@@ -1,95 +1,5 @@
package v0alpha1
import (
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
data "github.com/grafana/grafana-plugin-sdk-go/experimental/apis/data/v0alpha1"
common "github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1"
)
// +k8s:deepcopy-gen=true
// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object
type LibraryPanel struct {
metav1.TypeMeta `json:",inline"`
// Standard object's metadata
// More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#metadata
// +optional
metav1.ObjectMeta `json:"metadata,omitempty"`
// Panel properties
Spec LibraryPanelSpec `json:"spec"`
// Status will show errors
Status *LibraryPanelStatus `json:"status,omitempty"`
}
// +k8s:deepcopy-gen=true
// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object
type LibraryPanelList struct {
metav1.TypeMeta `json:",inline"`
// +optional
metav1.ListMeta `json:"metadata,omitempty"`
Items []LibraryPanel `json:"items"`
}
// +k8s:deepcopy-gen=true
type LibraryPanelSpec struct {
// The panel type
Type string `json:"type"`
// The panel type
PluginVersion string `json:"pluginVersion,omitempty"`
// The title of the library panel
Title string `json:"title,omitempty"`
// The title of the panel when displayed in the dashboard
PanelTitle string `json:"panelTitle,omitempty"`
// Library panel description
Description string `json:"description,omitempty"`
// The options schema depends on the panel type
Options common.Unstructured `json:"options"`
// The fieldConfig schema depends on the panel type
FieldConfig common.Unstructured `json:"fieldConfig"`
// The default datasource type
Datasource *data.DataSourceRef `json:"datasource,omitempty"`
// The grid position
GridPos GridPos `json:"gridPos,omitempty"`
// Whether the panel is transparent
Transparent bool `json:"transparent,omitempty"`
// The links for the panel
Links []common.Unstructured `json:"links,omitempty"`
// The datasource queries
// +listType=set
Targets []data.DataQuery `json:"targets,omitempty"`
}
// +k8s:deepcopy-gen=true
type GridPos struct {
W int `json:"w"`
H int `json:"h"`
X int `json:"x"`
Y int `json:"y"`
}
// +k8s:deepcopy-gen=true
type LibraryPanelStatus struct {
// Translation warnings (mostly things that were in SQL columns but not found in the saved body)
Warnings []string `json:"warnings,omitempty"`
// The properties previously stored in SQL that are not included in this model
Missing common.Unstructured `json:"missing,omitempty"`
}
// This is like the legacy DTO where access and metadata are all returned in a single call
// +k8s:deepcopy-gen=true
// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object
@@ -882,7 +882,7 @@ func schema_pkg_apis_dashboard_v0alpha1_LibraryPanelSpec(ref common.ReferenceCal
"targets": {
VendorExtensible: spec.VendorExtensible{
Extensions: spec.Extensions{
"x-kubernetes-list-type": "set",
"x-kubernetes-list-type": "atomic",
},
},
SchemaProps: spec.SchemaProps{
@@ -52,7 +52,7 @@ func (u *Unstructured) SetUnstructuredContent(content map[string]any) {
// MarshalJSON ensures that the unstructured object produces proper
// JSON when passed to Go's standard JSON library.
func (u *Unstructured) MarshalJSON() ([]byte, error) {
func (u Unstructured) MarshalJSON() ([]byte, error) {
return json.Marshal(u.Object)
}
+4
View File
@@ -125,6 +125,10 @@ var serviceIdentityPermissions = getWildcardPermissions(
"datasources:query",
"datasources:read",
"datasources:delete",
"library.panels:create", // ActionLibraryPanelsCreate
"library.panels:read", // ActionLibraryPanelsRead
"library.panels:write", // ActionLibraryPanelsWrite
"library.panels:delete", // ActionLibraryPanelsDelete
"alert.provisioning:write",
"alert.provisioning.secrets:read",
"users:read", // accesscontrol.ActionUsersRead,
+64 -8
View File
@@ -5,16 +5,17 @@ import (
"fmt"
"strconv"
apierrors "k8s.io/apimachinery/pkg/api/errors"
"k8s.io/apimachinery/pkg/apis/meta/internalversion"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apiserver/pkg/registry/rest"
"github.com/grafana/grafana/pkg/apimachinery/identity"
"github.com/grafana/grafana/pkg/apimachinery/utils"
"github.com/grafana/grafana/pkg/registry/apis/dashboard/legacy"
"github.com/grafana/grafana/pkg/services/accesscontrol"
"github.com/grafana/grafana/pkg/services/apiserver/endpoints/request"
apierrors "k8s.io/apimachinery/pkg/api/errors"
"github.com/grafana/grafana/pkg/services/libraryelements"
)
var (
@@ -29,9 +30,9 @@ var (
)
type LibraryPanelStore struct {
Access legacy.DashboardAccess
ResourceInfo utils.ResourceInfo
AccessControl accesscontrol.AccessControl
Access legacy.DashboardAccess
ResourceInfo utils.ResourceInfo
service libraryelements.Service
}
func (s *LibraryPanelStore) New() runtime.Object {
@@ -57,15 +58,70 @@ func (s *LibraryPanelStore) ConvertToTable(ctx context.Context, object runtime.O
}
func (s *LibraryPanelStore) Create(ctx context.Context, obj runtime.Object, createValidation rest.ValidateObjectFunc, options *metav1.CreateOptions) (runtime.Object, error) {
return nil, fmt.Errorf("method not yet implemented")
user, err := identity.GetRequester(ctx)
if err != nil {
return nil, err
}
cmd, err := libraryelements.ToCreateLibraryElementCommand(obj)
if err != nil {
return nil, err
}
// NOTE: this includes all access control checks
out, err := s.service.CreateElement(ctx, user, *cmd)
if err != nil {
return nil, err
}
if out.UID == "" {
return nil, fmt.Errorf("created library panel has empty UID")
}
return s.Get(ctx, out.UID, &metav1.GetOptions{})
}
func (s *LibraryPanelStore) Update(ctx context.Context, name string, objInfo rest.UpdatedObjectInfo, createValidation rest.ValidateObjectFunc, updateValidation rest.ValidateObjectUpdateFunc, forceAllowCreate bool, options *metav1.UpdateOptions) (runtime.Object, bool, error) {
return nil, false, fmt.Errorf("method not yet implemented")
user, err := identity.GetRequester(ctx)
if err != nil {
return nil, false, err
}
old, err := s.Get(ctx, name, &metav1.GetOptions{})
if err != nil {
return nil, false, err
}
// NOTE: this includes all access control checks
obj, err := objInfo.UpdatedObject(ctx, old)
if err != nil {
return nil, false, err
}
cmd, err := libraryelements.ToPatchLibraryElementCommand(obj)
if err != nil {
return nil, false, err
}
out, err := s.service.PatchLibraryElement(ctx, user, *cmd, name)
if err != nil {
return nil, false, err
}
if out.UID == "" {
return nil, false, fmt.Errorf("created library panel has empty UID")
}
obj, err = s.Get(ctx, out.UID, &metav1.GetOptions{})
return obj, false, err
}
func (s *LibraryPanelStore) Delete(ctx context.Context, name string, deleteValidation rest.ValidateObjectFunc, options *metav1.DeleteOptions) (runtime.Object, bool, error) {
return nil, false, fmt.Errorf("method not yet implemented")
user, err := identity.GetRequester(ctx)
if err != nil {
return nil, false, err
}
// NOTE: this includes all access control checks
_, err = s.service.DeleteLibraryElement(ctx, user, name)
if err != nil {
return nil, false, err
}
return nil, true, nil
}
func (s *LibraryPanelStore) DeleteCollection(ctx context.Context, deleteValidation rest.ValidateObjectFunc, options *metav1.DeleteOptions, listOptions *internalversion.ListOptions) (runtime.Object, error) {
+13
View File
@@ -26,6 +26,19 @@ func (b *DashboardsAPIBuilder) Mutate(ctx context.Context, a admission.Attribute
if op != admission.Create && op != admission.Update {
return nil
}
switch a.GetResource().Resource {
case dashboardV0.DASHBOARD_RESOURCE:
return b.mutateDashboard(ctx, a)
case dashboardV0.LIBRARY_PANEL_RESOURCE:
return nil // nothing needed
}
return fmt.Errorf("unexpected resource: %+v", a.GetResource())
}
func (b *DashboardsAPIBuilder) mutateDashboard(ctx context.Context, a admission.Attributes) (err error) {
var internalID int64
obj := a.GetObject()
meta, err := utils.MetaAccessor(obj)
+2 -3
View File
@@ -7,7 +7,6 @@ import (
"github.com/stretchr/testify/require"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/apiserver/pkg/admission"
dashv0 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v0alpha1"
@@ -180,10 +179,10 @@ func TestDashboardAPIBuilder_Mutate(t *testing.T) {
err := b.Mutate(context.Background(), admission.NewAttributesRecord(
tt.inputObj,
nil,
schema.GroupVersionKind{},
dashv1.DashboardResourceInfo.GroupVersionKind(),
"",
"test",
schema.GroupVersionResource{},
dashv1.DashboardResourceInfo.GroupVersionResource(),
"",
tt.operation,
operationOptions,
+33 -17
View File
@@ -50,6 +50,7 @@ import (
dashsvc "github.com/grafana/grafana/pkg/services/dashboards/service"
"github.com/grafana/grafana/pkg/services/datasources"
"github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/services/libraryelements"
"github.com/grafana/grafana/pkg/services/librarypanels"
"github.com/grafana/grafana/pkg/services/provisioning"
"github.com/grafana/grafana/pkg/services/quota"
@@ -68,6 +69,8 @@ var (
_ builder.APIGroupVersionsProvider = (*DashboardsAPIBuilder)(nil)
_ builder.OpenAPIPostProcessor = (*DashboardsAPIBuilder)(nil)
_ builder.APIGroupRouteProvider = (*DashboardsAPIBuilder)(nil)
_ builder.APIGroupMutation = (*DashboardsAPIBuilder)(nil)
_ builder.APIGroupValidation = (*DashboardsAPIBuilder)(nil)
)
const (
@@ -108,6 +111,7 @@ type DashboardsAPIBuilder struct {
minRefreshInterval string
dualWriter dualwrite.Service
folderClientProvider client.K8sHandlerProvider
libraryPanels libraryelements.Service // for legacy library panels
isStandalone bool // skips any handling including anything to do with legacy storage
}
@@ -135,6 +139,7 @@ func RegisterAPIService(
libraryPanelSvc librarypanels.Service,
restConfigProvider apiserver.RestConfigProvider,
userService user.Service,
libraryPanels libraryelements.Service,
) *DashboardsAPIBuilder {
dbp := legacysql.NewDatabaseProvider(sql)
namespacer := request.GetNamespaceMapper(cfg)
@@ -157,6 +162,7 @@ func RegisterAPIService(
minRefreshInterval: cfg.MinRefreshInterval,
dualWriter: dual,
folderClientProvider: newSimpleFolderClientProvider(folderClient),
libraryPanels: libraryPanels,
legacy: &DashboardStorage{
Access: legacy.NewDashboardAccess(dbp, namespacer, dashStore, provisioning, libraryPanelSvc, sorter, dashboardPermissionsSvc, accessControl, features),
@@ -229,26 +235,35 @@ func (b *DashboardsAPIBuilder) InstallSchema(scheme *runtime.Scheme) error {
}
func (b *DashboardsAPIBuilder) AllowedV0Alpha1Resources() []string {
return []string{dashv0.DashboardKind().Plural()}
return []string{
dashv0.DashboardKind().Plural(),
dashv0.LIBRARY_PANEL_RESOURCE,
}
}
// Validate validates dashboard operations for the apiserver
func (b *DashboardsAPIBuilder) Validate(ctx context.Context, a admission.Attributes, o admission.ObjectInterfaces) (err error) {
op := a.GetOperation()
// Handle different operations
switch op {
case admission.Delete:
return b.validateDelete(ctx, a)
case admission.Create:
return b.validateCreate(ctx, a, o)
case admission.Update:
return b.validateUpdate(ctx, a, o)
case admission.Connect:
return nil
switch a.GetResource().Resource {
case dashv0.DASHBOARD_RESOURCE:
// Handle different operations
switch op {
case admission.Delete:
return b.validateDelete(ctx, a)
case admission.Create:
return b.validateCreate(ctx, a, o)
case admission.Update:
return b.validateUpdate(ctx, a, o)
case admission.Connect:
return nil
}
case dashv0.LIBRARY_PANEL_RESOURCE:
return nil // OK for now
}
return nil
return fmt.Errorf("unsupported validation: %+v", a.GetResource())
}
// validateDelete checks if a dashboard can be deleted
@@ -642,12 +657,13 @@ func (b *DashboardsAPIBuilder) storageForVersion(
return err
}
// Expose read only library panels
if libraryPanels != nil {
// Expose read library panels
//nolint:staticcheck // not yet migrated to OpenFeature
if libraryPanels != nil && b.features.IsEnabledGlobally(featuremgmt.FlagGrafanaAPIServerWithExperimentalAPIs) {
legacyLibraryStore := &LibraryPanelStore{
Access: b.legacy.Access,
ResourceInfo: *libraryPanels,
AccessControl: b.accessControl,
Access: b.legacy.Access,
ResourceInfo: *libraryPanels,
service: b.libraryPanels,
}
unifiedLibraryStore, err := grafanaregistry.NewRegistryStore(opts.Scheme, *libraryPanels, opts.OptsGetter)
+2 -2
View File
@@ -840,7 +840,7 @@ func Initialize(ctx context.Context, cfg *setting.Cfg, opts Options, apiOpts api
identitySynchronizer := authnimpl.ProvideIdentitySynchronizer(authnimplService)
ldapImpl := service12.ProvideService(cfg, featureToggles, ssosettingsimplService)
apiService := api4.ProvideService(cfg, routeRegisterImpl, accessControl, userService, authinfoimplService, ossGroups, identitySynchronizer, orgService, ldapImpl, userAuthTokenService, bundleregistryService)
dashboardsAPIBuilder := dashboard.RegisterAPIService(cfg, featureToggles, apiserverService, dashboardService, dashboardProvisioningService, service15, dashboardServiceImpl, dashboardPermissionsService, accessControl, accessClient, provisioningServiceImpl, dashboardsStore, registerer, sqlStore, tracingService, resourceClient, dualwriteService, sortService, quotaService, libraryPanelService, eventualRestConfigProvider, userService)
dashboardsAPIBuilder := dashboard.RegisterAPIService(cfg, featureToggles, apiserverService, dashboardService, dashboardProvisioningService, service15, dashboardServiceImpl, dashboardPermissionsService, accessControl, accessClient, provisioningServiceImpl, dashboardsStore, registerer, sqlStore, tracingService, resourceClient, dualwriteService, sortService, quotaService, libraryPanelService, eventualRestConfigProvider, userService, libraryElementService)
snapshotsAPIBuilder := dashboardsnapshot.RegisterAPIService(serviceImpl, apiserverService, cfg, featureToggles, sqlStore, registerer)
dataSourceAPIBuilder, err := datasource.RegisterAPIService(configProvider, featureToggles, apiserverService, middlewareHandler, scopedPluginDatasourceProvider, plugincontextProvider, accessControl, registerer)
if err != nil {
@@ -1470,7 +1470,7 @@ func InitializeForTest(ctx context.Context, t sqlutil.ITestDB, testingT interfac
identitySynchronizer := authnimpl.ProvideIdentitySynchronizer(authnimplService)
ldapImpl := service12.ProvideService(cfg, featureToggles, ssosettingsimplService)
apiService := api4.ProvideService(cfg, routeRegisterImpl, accessControl, userService, authinfoimplService, ossGroups, identitySynchronizer, orgService, ldapImpl, userAuthTokenService, bundleregistryService)
dashboardsAPIBuilder := dashboard.RegisterAPIService(cfg, featureToggles, apiserverService, dashboardService, dashboardProvisioningService, service15, dashboardServiceImpl, dashboardPermissionsService, accessControl, accessClient, provisioningServiceImpl, dashboardsStore, registerer, sqlStore, tracingService, resourceClient, dualwriteService, sortService, quotaService, libraryPanelService, eventualRestConfigProvider, userService)
dashboardsAPIBuilder := dashboard.RegisterAPIService(cfg, featureToggles, apiserverService, dashboardService, dashboardProvisioningService, service15, dashboardServiceImpl, dashboardPermissionsService, accessControl, accessClient, provisioningServiceImpl, dashboardsStore, registerer, sqlStore, tracingService, resourceClient, dualwriteService, sortService, quotaService, libraryPanelService, eventualRestConfigProvider, userService, libraryElementService)
snapshotsAPIBuilder := dashboardsnapshot.RegisterAPIService(serviceImpl, apiserverService, cfg, featureToggles, sqlStore, registerer)
dataSourceAPIBuilder, err := datasource.RegisterAPIService(configProvider, featureToggles, apiserverService, middlewareHandler, scopedPluginDatasourceProvider, plugincontextProvider, accessControl, registerer)
if err != nil {
+3 -3
View File
@@ -87,7 +87,7 @@ func (l *LibraryElementService) createHandler(c *contextmodel.ReqContext) respon
}
}
element, err := l.createLibraryElement(c.Req.Context(), c.SignedInUser, cmd)
element, err := l.CreateElement(c.Req.Context(), c.SignedInUser, cmd)
if err != nil {
return l.toLibraryElementError(err, "Failed to create library element")
}
@@ -124,7 +124,7 @@ func (l *LibraryElementService) createHandler(c *contextmodel.ReqContext) respon
// 404: notFoundError
// 500: internalServerError
func (l *LibraryElementService) deleteHandler(c *contextmodel.ReqContext) response.Response {
id, err := l.deleteLibraryElement(c.Req.Context(), c.SignedInUser, web.Params(c.Req)[":uid"])
id, err := l.DeleteLibraryElement(c.Req.Context(), c.SignedInUser, web.Params(c.Req)[":uid"])
if err != nil {
return l.toLibraryElementError(err, "Failed to delete library element")
}
@@ -252,7 +252,7 @@ func (l *LibraryElementService) patchHandler(c *contextmodel.ReqContext) respons
}
}
element, err := l.patchLibraryElement(c.Req.Context(), c.SignedInUser, cmd, web.Params(c.Req)[":uid"])
element, err := l.PatchLibraryElement(c.Req.Context(), c.SignedInUser, cmd, web.Params(c.Req)[":uid"])
if err != nil {
return l.toLibraryElementError(err, "Failed to update library element")
}
@@ -0,0 +1,59 @@
package libraryelements
import (
"encoding/json"
"fmt"
"k8s.io/apimachinery/pkg/runtime"
"github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v0alpha1"
"github.com/grafana/grafana/pkg/apimachinery/utils"
"github.com/grafana/grafana/pkg/services/libraryelements/model"
"github.com/grafana/grafana/pkg/util"
)
func ToCreateLibraryElementCommand(raw runtime.Object) (*model.CreateLibraryElementCommand, error) {
obj, err := utils.MetaAccessor(raw)
if err != nil {
return nil, err
}
folder := obj.GetFolder()
cmd := &model.CreateLibraryElementCommand{
UID: obj.GetName(),
FolderUID: &folder,
Kind: 1, // the only kind... LibraryPanel
Name: obj.FindTitle("library panel"),
}
if cmd.UID == "" {
if obj.GetGenerateName() == "" {
return nil, fmt.Errorf("expecting either name or generateName property")
}
cmd.UID = obj.GetGenerateName() + util.GenerateShortUID()
}
cmd.Model, err = toRawMessage(raw)
return cmd, err
}
func ToPatchLibraryElementCommand(raw runtime.Object) (*model.PatchLibraryElementCommand, error) {
obj, err := utils.MetaAccessor(raw)
if err != nil {
return nil, err
}
folder := obj.GetFolder()
cmd := &model.PatchLibraryElementCommand{
UID: obj.GetName(),
FolderUID: &folder,
Kind: 1, // the only kind... LibraryPanel
Name: obj.FindTitle("library panel"),
}
cmd.Model, err = toRawMessage(raw)
return cmd, err
}
func toRawMessage(raw runtime.Object) (json.RawMessage, error) {
switch obj := raw.(type) {
case *v0alpha1.LibraryPanel:
return json.Marshal(obj.Spec)
}
return nil, fmt.Errorf("unsupported library panel type: %T", raw)
}
@@ -0,0 +1,101 @@
package libraryelements
import (
"encoding/json"
"testing"
"github.com/google/go-cmp/cmp"
"github.com/stretchr/testify/require"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/utils/ptr"
data "github.com/grafana/grafana-plugin-sdk-go/experimental/apis/data/v0alpha1"
"github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v0alpha1"
common "github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1"
"github.com/grafana/grafana/pkg/apimachinery/utils"
"github.com/grafana/grafana/pkg/services/libraryelements/model"
)
func TestConversionsCommands(t *testing.T) {
cases := []struct {
name string
input runtime.Object
expectedCreate *model.CreateLibraryElementCommand
expectedPatch *model.PatchLibraryElementCommand
}{
{
name: "basic conversion",
input: &v0alpha1.LibraryPanel{
ObjectMeta: metav1.ObjectMeta{
Name: "uid",
Annotations: map[string]string{
utils.AnnoKeyFolder: "aaa",
},
},
Spec: v0alpha1.LibraryPanelSpec{
Type: "timeseries",
PluginVersion: "1.2.3",
Title: "title",
Description: "descr",
Options: common.Unstructured{
Object: map[string]any{
"hello": "options",
},
},
FieldConfig: common.Unstructured{
Object: map[string]any{
"hello": "fieldConfig",
},
},
PanelTitle: "panel title",
GridPos: v0alpha1.GridPos{
W: 1, H: 2, X: 3, Y: 4,
},
Transparent: true,
Links: []common.Unstructured{{
Object: map[string]any{
"link1": "hello",
},
}},
Datasource: &data.DataSourceRef{
UID: "uid",
Type: "ttt",
APIVersion: "v0alpha1",
},
},
},
expectedCreate: &model.CreateLibraryElementCommand{
FolderUID: ptr.To("aaa"),
UID: "uid",
Name: "title",
Kind: 1,
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"}]}`),
},
expectedPatch: &model.PatchLibraryElementCommand{
FolderUID: ptr.To("aaa"),
UID: "uid",
Name: "title",
Kind: 1,
Version: 0,
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"}]}`),
},
},
}
for _, tt := range cases {
t.Run(tt.name, func(t *testing.T) {
create, err := ToCreateLibraryElementCommand(tt.input)
require.NoError(t, err)
if diff := cmp.Diff(tt.expectedCreate, create); diff != "" {
require.FailNowf(t, "Create mismatch (-want +got):%s", diff)
}
patch, err := ToPatchLibraryElementCommand(tt.input)
require.NoError(t, err)
if diff := cmp.Diff(tt.expectedPatch, patch); diff != "" {
require.FailNowf(t, "Path mismatch (-want +got):%s", diff)
}
})
}
}
+4 -4
View File
@@ -110,7 +110,7 @@ func (l *LibraryElementService) GetLibraryElement(c context.Context, signedInUse
}
// createLibraryElement adds a library element.
func (l *LibraryElementService) createLibraryElement(c context.Context, signedInUser identity.Requester, cmd model.CreateLibraryElementCommand) (model.LibraryElementDTO, error) {
func (l *LibraryElementService) CreateElement(c context.Context, signedInUser identity.Requester, cmd model.CreateLibraryElementCommand) (model.LibraryElementDTO, error) {
if err := l.requireSupportedElementKind(cmd.Kind); err != nil {
return model.LibraryElementDTO{}, err
}
@@ -169,7 +169,7 @@ func (l *LibraryElementService) createLibraryElement(c context.Context, signedIn
err = l.SQLStore.WithTransactionalDbSession(c, func(session *db.Session) error {
allowed, err := l.AccessControl.Evaluate(c, signedInUser, ac.EvalPermission(ActionLibraryPanelsCreate, dashboards.ScopeFoldersProvider.GetResourceScopeUID(folderUID)))
if !allowed {
return fmt.Errorf("insufficient permissions for creating library panel in folder with UID %s", folderUID)
return fmt.Errorf("insufficient permissions for creating library panel in folder with UID: '%s'", folderUID)
}
if err != nil {
return err
@@ -216,7 +216,7 @@ func (l *LibraryElementService) createLibraryElement(c context.Context, signedIn
}
// deleteLibraryElement deletes a library element.
func (l *LibraryElementService) deleteLibraryElement(c context.Context, signedInUser identity.Requester, uid string) (int64, error) {
func (l *LibraryElementService) DeleteLibraryElement(c context.Context, signedInUser identity.Requester, uid string) (int64, error) {
var elementID int64
err := l.SQLStore.WithTransactionalDbSession(c, func(session *db.Session) error {
element, err := l.GetLibraryElement(c, signedInUser, session, uid)
@@ -578,7 +578,7 @@ func (l *LibraryElementService) handleFolderIDPatches(ctx context.Context, eleme
}
// patchLibraryElement updates a Library Element.
func (l *LibraryElementService) patchLibraryElement(c context.Context, signedInUser identity.Requester, cmd model.PatchLibraryElementCommand, uid string) (model.LibraryElementDTO, error) {
func (l *LibraryElementService) PatchLibraryElement(c context.Context, signedInUser identity.Requester, cmd model.PatchLibraryElementCommand, uid string) (model.LibraryElementDTO, error) {
var dto model.LibraryElementDTO
if err := l.requireSupportedElementKind(cmd.Kind); err != nil {
return model.LibraryElementDTO{}, err
@@ -17,6 +17,16 @@ type LibraryElementService struct {
idCounter int64
}
// DeleteLibraryElement implements libraryelements.Service.
func (l *LibraryElementService) DeleteLibraryElement(c context.Context, signedInUser identity.Requester, uid string) (int64, error) {
panic("unimplemented")
}
// PatchLibraryElement implements libraryelements.Service.
func (l *LibraryElementService) PatchLibraryElement(c context.Context, signedInUser identity.Requester, cmd model.PatchLibraryElementCommand, uid string) (model.LibraryElementDTO, error) {
panic("unimplemented")
}
var _ libraryelements.Service = (*LibraryElementService)(nil)
func (l *LibraryElementService) CreateElement(c context.Context, signedInUser identity.Requester, cmd model.CreateLibraryElementCommand) (model.LibraryElementDTO, error) {
@@ -68,6 +78,11 @@ func (l *LibraryElementService) CreateElement(c context.Context, signedInUser id
return dto, nil
}
// PatchElement implements libraryelements.Service.
func (l *LibraryElementService) PatchElement(c context.Context, signedInUser identity.Requester, cmd model.PatchLibraryElementCommand, uid string) (model.LibraryElementDTO, error) {
panic("unimplemented")
}
func (l *LibraryElementService) GetElement(c context.Context, signedInUser identity.Requester, cmd model.GetLibraryElementCommand) (model.LibraryElementDTO, error) {
l.mx.RLock()
defer l.mx.RUnlock()
@@ -2,13 +2,13 @@ package libraryelements
import (
"context"
"encoding/json"
"net/http/httptest"
"testing"
"time"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"github.com/grafana/grafana/pkg/api/dtos"
@@ -23,7 +23,6 @@ import (
"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) {
@@ -155,26 +154,22 @@ func TestUnstructuredToLegacyLibraryPanelDTO(t *testing.T) {
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"])
// fmt.Printf("%s\n", result.Model)
require.JSONEq(t, `{
"datasource": { "type": "testdata", "uid": "test-datasource" },
"description": "Test description",
"fieldConfig": { "defaults": { "color": { "mode": "palette-classic" } } },
"gridPos": { "w": 12, "h": 8, "x": 0, "y": 0 },
"id": 123,
"libraryPanel": { "name": "Test Library Panel", "uid": "test-panel-uid" },
"links": [{ "title": "Test Link", "url": "https://example.com" }],
"options": { "content": "Test content" },
"pluginVersion": "1.0.0",
"targets": [{ "refId": "A", "expr": "test_query" }],
"title": "Test Panel Title",
"transparent": true,
"type": "text"
}`, string(result.Model))
dashboardsSvc.AssertExpectations(t)
}
@@ -40,6 +40,8 @@ func ProvideService(cfg *setting.Cfg, sqlStore db.DB, routeRegister routing.Rout
// Service is a service for operating on library elements.
type Service interface {
CreateElement(c context.Context, signedInUser identity.Requester, cmd model.CreateLibraryElementCommand) (model.LibraryElementDTO, error)
PatchLibraryElement(c context.Context, signedInUser identity.Requester, cmd model.PatchLibraryElementCommand, uid string) (model.LibraryElementDTO, error)
DeleteLibraryElement(c context.Context, signedInUser identity.Requester, uid string) (int64, error)
GetElement(c context.Context, signedInUser identity.Requester, cmd model.GetLibraryElementCommand) (model.LibraryElementDTO, error)
GetElementsForDashboard(c context.Context, dashboardID int64) (map[string]model.LibraryElementDTO, error)
ConnectElementsToDashboard(c context.Context, signedInUser identity.Requester, elementUIDs []string, dashboardID int64) error
@@ -63,11 +65,6 @@ type LibraryElementService struct {
var _ Service = (*LibraryElementService)(nil)
// CreateElement creates a Library Element.
func (l *LibraryElementService) CreateElement(c context.Context, signedInUser identity.Requester, cmd model.CreateLibraryElementCommand) (model.LibraryElementDTO, error) {
return l.createLibraryElement(c, signedInUser, cmd)
}
// GetElement gets an element from a UID.
func (l *LibraryElementService) GetElement(c context.Context, signedInUser identity.Requester, cmd model.GetLibraryElementCommand) (model.LibraryElementDTO, error) {
return l.getLibraryElementByUid(c, signedInUser, cmd)
@@ -103,7 +103,7 @@ func TestIntegration_GetLibraryElement(t *testing.T) {
newFolder := createFolder(t, sc, "NewFolder", sc.folderSvc)
sc.reqContext.Permissions[sc.reqContext.OrgID][dashboards.ActionFoldersRead] = []string{dashboards.ScopeFoldersAll}
sc.reqContext.Permissions[sc.reqContext.OrgID][dashboards.ActionFoldersDelete] = []string{dashboards.ScopeFoldersAll}
result, err := sc.service.createLibraryElement(sc.reqContext.Req.Context(), sc.reqContext.SignedInUser, model.CreateLibraryElementCommand{
result, err := sc.service.CreateElement(sc.reqContext.Req.Context(), sc.reqContext.SignedInUser, model.CreateLibraryElementCommand{
FolderID: newFolder.ID, // nolint:staticcheck
FolderUID: &newFolder.UID,
Name: "Testing Library Panel With Deleted Folder",
@@ -150,7 +150,7 @@ func TestIntegration_GetLibraryPanelConnections(t *testing.T) {
newFolder := createFolder(t, sc, "NewFolder", sc.folderSvc)
sc.reqContext.Permissions[sc.reqContext.OrgID][dashboards.ActionFoldersRead] = []string{dashboards.ScopeFoldersAll}
sc.reqContext.Permissions[sc.reqContext.OrgID][dashboards.ActionFoldersDelete] = []string{dashboards.ScopeFoldersAll}
_, err = sc.service.createLibraryElement(sc.reqContext.Req.Context(), sc.reqContext.SignedInUser, model.CreateLibraryElementCommand{
_, err = sc.service.CreateElement(sc.reqContext.Req.Context(), sc.reqContext.SignedInUser, model.CreateLibraryElementCommand{
FolderID: newFolder.ID, // nolint:staticcheck
FolderUID: &newFolder.UID,
Name: "Testing Library Panel With Deleted Folder",
+101
View File
@@ -0,0 +1,101 @@
{
"kind": "LibraryPanel",
"apiVersion": "dashboard.grafana.app/v0alpha1",
"metadata": {
"generateName": "x"
},
"spec": {
"type": "timeseries",
"pluginVersion": "12.3.0-pre",
"title": "example library panel",
"panelTitle": "simple",
"options": {
"legend": {
"calcs": [],
"displayMode": "list",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"hideZeros": false,
"mode": "single",
"sort": "none"
}
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"custom": {
"axisBorderShow": false,
"axisCenteredZero": false,
"axisColorMode": "text",
"axisLabel": "",
"axisPlacement": "auto",
"barAlignment": 0,
"barWidthFactor": 0.6,
"drawStyle": "line",
"fillOpacity": 0,
"gradientMode": "none",
"hideFrom": {
"legend": false,
"tooltip": false,
"viz": false
},
"insertNulls": false,
"lineInterpolation": "linear",
"lineWidth": 1,
"pointSize": 5,
"scaleDistribution": {
"type": "linear"
},
"showPoints": "auto",
"showValues": false,
"spanNulls": false,
"stacking": {
"group": "A",
"mode": "none"
},
"thresholdsStyle": {
"mode": "off"
}
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
},
{
"color": "red",
"value": 80
}
]
}
},
"overrides": []
},
"datasource": {
"type": "grafana-testdata-datasource",
"uid": "cejobd88i85j4d"
},
"gridPos": {
"w": 12,
"h": 8,
"x": 0,
"y": 0
},
"targets": [
{
"refId": "A",
"datasource": {
"type": "grafana-testdata-datasource",
"uid": "cejobd88i85j4d"
}
}
]
}
}
+101
View File
@@ -0,0 +1,101 @@
{
"kind": "LibraryPanel",
"apiVersion": "dashboard.grafana.app/v0alpha1",
"metadata": {
"name": "test1"
},
"spec": {
"type": "timeseries",
"pluginVersion": "12.3.0-pre",
"title": "example library panel (test1)",
"panelTitle": "simple",
"options": {
"legend": {
"calcs": [],
"displayMode": "list",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"hideZeros": false,
"mode": "single",
"sort": "none"
}
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"custom": {
"axisBorderShow": false,
"axisCenteredZero": false,
"axisColorMode": "text",
"axisLabel": "",
"axisPlacement": "auto",
"barAlignment": 0,
"barWidthFactor": 0.6,
"drawStyle": "line",
"fillOpacity": 0,
"gradientMode": "none",
"hideFrom": {
"legend": false,
"tooltip": false,
"viz": false
},
"insertNulls": false,
"lineInterpolation": "linear",
"lineWidth": 1,
"pointSize": 5,
"scaleDistribution": {
"type": "linear"
},
"showPoints": "auto",
"showValues": false,
"spanNulls": false,
"stacking": {
"group": "A",
"mode": "none"
},
"thresholdsStyle": {
"mode": "off"
}
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
},
{
"color": "red",
"value": 80
}
]
}
},
"overrides": []
},
"datasource": {
"type": "grafana-testdata-datasource",
"uid": "cejobd88i85j4d"
},
"gridPos": {
"w": 12,
"h": 8,
"x": 0,
"y": 0
},
"targets": [
{
"refId": "A",
"datasource": {
"type": "grafana-testdata-datasource",
"uid": "cejobd88i85j4d"
}
}
]
}
}
@@ -2694,7 +2694,7 @@
"items": {
"$ref": "#/components/schemas/com.github.grafana.grafana-plugin-sdk-go.experimental.apis.data.v0alpha1.DataQuery"
},
"x-kubernetes-list-type": "set"
"x-kubernetes-list-type": "atomic"
},
"title": {
"description": "The title of the library panel",