diff --git a/apps/dashboard/pkg/apis/dashboard/v0alpha1/librarypanel.go b/apps/dashboard/pkg/apis/dashboard/v0alpha1/librarypanel.go new file mode 100644 index 00000000000..be4ea4d037c --- /dev/null +++ b/apps/dashboard/pkg/apis/dashboard/v0alpha1/librarypanel.go @@ -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"` +} diff --git a/apps/dashboard/pkg/apis/dashboard/v0alpha1/types.go b/apps/dashboard/pkg/apis/dashboard/v0alpha1/types.go index 141141f861f..7f813a12a1f 100644 --- a/apps/dashboard/pkg/apis/dashboard/v0alpha1/types.go +++ b/apps/dashboard/pkg/apis/dashboard/v0alpha1/types.go @@ -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 diff --git a/apps/dashboard/pkg/apis/dashboard/v0alpha1/zz_generated.openapi.go b/apps/dashboard/pkg/apis/dashboard/v0alpha1/zz_generated.openapi.go index efb39a56d7d..1e81a87f2fa 100644 --- a/apps/dashboard/pkg/apis/dashboard/v0alpha1/zz_generated.openapi.go +++ b/apps/dashboard/pkg/apis/dashboard/v0alpha1/zz_generated.openapi.go @@ -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{ diff --git a/pkg/apimachinery/apis/common/v0alpha1/unstructured.go b/pkg/apimachinery/apis/common/v0alpha1/unstructured.go index db4c2e73302..3046d898049 100644 --- a/pkg/apimachinery/apis/common/v0alpha1/unstructured.go +++ b/pkg/apimachinery/apis/common/v0alpha1/unstructured.go @@ -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) } diff --git a/pkg/apimachinery/identity/context.go b/pkg/apimachinery/identity/context.go index 664f519dd8f..1ce76389f59 100644 --- a/pkg/apimachinery/identity/context.go +++ b/pkg/apimachinery/identity/context.go @@ -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, diff --git a/pkg/registry/apis/dashboard/libary_panel.go b/pkg/registry/apis/dashboard/libary_panel.go index c22594ff590..aa2b2705bb1 100644 --- a/pkg/registry/apis/dashboard/libary_panel.go +++ b/pkg/registry/apis/dashboard/libary_panel.go @@ -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) { diff --git a/pkg/registry/apis/dashboard/mutate.go b/pkg/registry/apis/dashboard/mutate.go index 3703e14c796..c193c17cf56 100644 --- a/pkg/registry/apis/dashboard/mutate.go +++ b/pkg/registry/apis/dashboard/mutate.go @@ -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) diff --git a/pkg/registry/apis/dashboard/mutation_test.go b/pkg/registry/apis/dashboard/mutation_test.go index 4c940160716..ad34c3b3218 100644 --- a/pkg/registry/apis/dashboard/mutation_test.go +++ b/pkg/registry/apis/dashboard/mutation_test.go @@ -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, diff --git a/pkg/registry/apis/dashboard/register.go b/pkg/registry/apis/dashboard/register.go index dadc2c079b4..b21877720cd 100644 --- a/pkg/registry/apis/dashboard/register.go +++ b/pkg/registry/apis/dashboard/register.go @@ -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) diff --git a/pkg/server/wire_gen.go b/pkg/server/wire_gen.go index 97c194f783b..cc02de5c578 100644 --- a/pkg/server/wire_gen.go +++ b/pkg/server/wire_gen.go @@ -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 { diff --git a/pkg/services/libraryelements/api.go b/pkg/services/libraryelements/api.go index 791cef618a9..312905cf0c8 100644 --- a/pkg/services/libraryelements/api.go +++ b/pkg/services/libraryelements/api.go @@ -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") } diff --git a/pkg/services/libraryelements/conversions.go b/pkg/services/libraryelements/conversions.go new file mode 100644 index 00000000000..465830d1697 --- /dev/null +++ b/pkg/services/libraryelements/conversions.go @@ -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) +} diff --git a/pkg/services/libraryelements/conversions_test.go b/pkg/services/libraryelements/conversions_test.go new file mode 100644 index 00000000000..2ae76fb9fa4 --- /dev/null +++ b/pkg/services/libraryelements/conversions_test.go @@ -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) + } + }) + } +} diff --git a/pkg/services/libraryelements/database.go b/pkg/services/libraryelements/database.go index d4923ffa20a..3a47b3095ce 100644 --- a/pkg/services/libraryelements/database.go +++ b/pkg/services/libraryelements/database.go @@ -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 diff --git a/pkg/services/libraryelements/fake/libraryelements_service.go b/pkg/services/libraryelements/fake/libraryelements_service.go index e8f0b3615d5..13f7aef322d 100644 --- a/pkg/services/libraryelements/fake/libraryelements_service.go +++ b/pkg/services/libraryelements/fake/libraryelements_service.go @@ -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() diff --git a/pkg/services/libraryelements/k8s_conversion_test.go b/pkg/services/libraryelements/k8s_conversion_test.go index 0319e0ae76a..689ef1f0c1d 100644 --- a/pkg/services/libraryelements/k8s_conversion_test.go +++ b/pkg/services/libraryelements/k8s_conversion_test.go @@ -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) } diff --git a/pkg/services/libraryelements/libraryelements.go b/pkg/services/libraryelements/libraryelements.go index fe31dfcfc70..540e54fc817 100644 --- a/pkg/services/libraryelements/libraryelements.go +++ b/pkg/services/libraryelements/libraryelements.go @@ -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) diff --git a/pkg/services/libraryelements/libraryelements_get_test.go b/pkg/services/libraryelements/libraryelements_get_test.go index c85c22ee676..1d9912195dd 100644 --- a/pkg/services/libraryelements/libraryelements_get_test.go +++ b/pkg/services/libraryelements/libraryelements_get_test.go @@ -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", diff --git a/pkg/services/libraryelements/libraryelements_test.go b/pkg/services/libraryelements/libraryelements_test.go index e782c20ac03..9340c19409f 100644 --- a/pkg/services/libraryelements/libraryelements_test.go +++ b/pkg/services/libraryelements/libraryelements_test.go @@ -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", diff --git a/pkg/tests/apis/dashboard/testdata/panel-generate.json b/pkg/tests/apis/dashboard/testdata/panel-generate.json new file mode 100644 index 00000000000..93c7f1d2241 --- /dev/null +++ b/pkg/tests/apis/dashboard/testdata/panel-generate.json @@ -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" + } + } + ] + } +} \ No newline at end of file diff --git a/pkg/tests/apis/dashboard/testdata/panel-test.json b/pkg/tests/apis/dashboard/testdata/panel-test.json new file mode 100644 index 00000000000..d7e13d18653 --- /dev/null +++ b/pkg/tests/apis/dashboard/testdata/panel-test.json @@ -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" + } + } + ] + } +} \ No newline at end of file diff --git a/pkg/tests/apis/openapi_snapshots/dashboard.grafana.app-v0alpha1.json b/pkg/tests/apis/openapi_snapshots/dashboard.grafana.app-v0alpha1.json index e68f94dda3e..b9c3ee36a91 100644 --- a/pkg/tests/apis/openapi_snapshots/dashboard.grafana.app-v0alpha1.json +++ b/pkg/tests/apis/openapi_snapshots/dashboard.grafana.app-v0alpha1.json @@ -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",