K8s: Library Panels: Add rerouting for get (#107362)
This commit is contained in:
@@ -82,9 +82,12 @@ type LibraryPanelSpec struct {
|
||||
// The panel type
|
||||
PluginVersion string `json:"pluginVersion,omitempty"`
|
||||
|
||||
// The panel title
|
||||
// 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"`
|
||||
|
||||
@@ -97,11 +100,28 @@ type LibraryPanelSpec struct {
|
||||
// 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)
|
||||
|
||||
@@ -9,6 +9,7 @@ package v0alpha1
|
||||
|
||||
import (
|
||||
datav0alpha1 "github.com/grafana/grafana-plugin-sdk-go/experimental/apis/data/v0alpha1"
|
||||
commonv0alpha1 "github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1"
|
||||
runtime "k8s.io/apimachinery/pkg/runtime"
|
||||
)
|
||||
|
||||
@@ -190,6 +191,22 @@ func (in *FacetResult) DeepCopy() *FacetResult {
|
||||
return out
|
||||
}
|
||||
|
||||
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
|
||||
func (in *GridPos) DeepCopyInto(out *GridPos) {
|
||||
*out = *in
|
||||
return
|
||||
}
|
||||
|
||||
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new GridPos.
|
||||
func (in *GridPos) DeepCopy() *GridPos {
|
||||
if in == nil {
|
||||
return nil
|
||||
}
|
||||
out := new(GridPos)
|
||||
in.DeepCopyInto(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
|
||||
func (in *LibraryPanel) DeepCopyInto(out *LibraryPanel) {
|
||||
*out = *in
|
||||
@@ -265,6 +282,14 @@ func (in *LibraryPanelSpec) DeepCopyInto(out *LibraryPanelSpec) {
|
||||
*out = new(datav0alpha1.DataSourceRef)
|
||||
**out = **in
|
||||
}
|
||||
out.GridPos = in.GridPos
|
||||
if in.Links != nil {
|
||||
in, out := &in.Links, &out.Links
|
||||
*out = make([]commonv0alpha1.Unstructured, len(*in))
|
||||
for i := range *in {
|
||||
(*in)[i].DeepCopyInto(&(*out)[i])
|
||||
}
|
||||
}
|
||||
if in.Targets != nil {
|
||||
in, out := &in.Targets, &out.Targets
|
||||
*out = make([]datav0alpha1.DataQuery, len(*in))
|
||||
|
||||
@@ -29,6 +29,7 @@ func GetOpenAPIDefinitions(ref common.ReferenceCallback) map[string]common.OpenA
|
||||
"github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v0alpha1.DashboardVersionList": schema_pkg_apis_dashboard_v0alpha1_DashboardVersionList(ref),
|
||||
"github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v0alpha1.DashboardWithAccessInfo": schema_pkg_apis_dashboard_v0alpha1_DashboardWithAccessInfo(ref),
|
||||
"github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v0alpha1.FacetResult": schema_pkg_apis_dashboard_v0alpha1_FacetResult(ref),
|
||||
"github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v0alpha1.GridPos": schema_pkg_apis_dashboard_v0alpha1_GridPos(ref),
|
||||
"github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v0alpha1.LibraryPanel": schema_pkg_apis_dashboard_v0alpha1_LibraryPanel(ref),
|
||||
"github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v0alpha1.LibraryPanelList": schema_pkg_apis_dashboard_v0alpha1_LibraryPanelList(ref),
|
||||
"github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v0alpha1.LibraryPanelSpec": schema_pkg_apis_dashboard_v0alpha1_LibraryPanelSpec(ref),
|
||||
@@ -713,6 +714,47 @@ func schema_pkg_apis_dashboard_v0alpha1_FacetResult(ref common.ReferenceCallback
|
||||
}
|
||||
}
|
||||
|
||||
func schema_pkg_apis_dashboard_v0alpha1_GridPos(ref common.ReferenceCallback) common.OpenAPIDefinition {
|
||||
return common.OpenAPIDefinition{
|
||||
Schema: spec.Schema{
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Type: []string{"object"},
|
||||
Properties: map[string]spec.Schema{
|
||||
"w": {
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Default: 0,
|
||||
Type: []string{"integer"},
|
||||
Format: "int32",
|
||||
},
|
||||
},
|
||||
"h": {
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Default: 0,
|
||||
Type: []string{"integer"},
|
||||
Format: "int32",
|
||||
},
|
||||
},
|
||||
"x": {
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Default: 0,
|
||||
Type: []string{"integer"},
|
||||
Format: "int32",
|
||||
},
|
||||
},
|
||||
"y": {
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Default: 0,
|
||||
Type: []string{"integer"},
|
||||
Format: "int32",
|
||||
},
|
||||
},
|
||||
},
|
||||
Required: []string{"w", "h", "x", "y"},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func schema_pkg_apis_dashboard_v0alpha1_LibraryPanel(ref common.ReferenceCallback) common.OpenAPIDefinition {
|
||||
return common.OpenAPIDefinition{
|
||||
Schema: spec.Schema{
|
||||
@@ -833,7 +875,14 @@ func schema_pkg_apis_dashboard_v0alpha1_LibraryPanelSpec(ref common.ReferenceCal
|
||||
},
|
||||
"title": {
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Description: "The panel title",
|
||||
Description: "The title of the library panel",
|
||||
Type: []string{"string"},
|
||||
Format: "",
|
||||
},
|
||||
},
|
||||
"panelTitle": {
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Description: "The title of the panel when displayed in the dashboard",
|
||||
Type: []string{"string"},
|
||||
Format: "",
|
||||
},
|
||||
@@ -863,6 +912,33 @@ func schema_pkg_apis_dashboard_v0alpha1_LibraryPanelSpec(ref common.ReferenceCal
|
||||
Ref: ref("github.com/grafana/grafana-plugin-sdk-go/experimental/apis/data/v0alpha1.DataSourceRef"),
|
||||
},
|
||||
},
|
||||
"gridPos": {
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Description: "The grid position",
|
||||
Default: map[string]interface{}{},
|
||||
Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v0alpha1.GridPos"),
|
||||
},
|
||||
},
|
||||
"transparent": {
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Description: "Whether the panel is transparent",
|
||||
Type: []string{"boolean"},
|
||||
Format: "",
|
||||
},
|
||||
},
|
||||
"links": {
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Description: "The links for the panel",
|
||||
Type: []string{"array"},
|
||||
Items: &spec.SchemaOrArray{
|
||||
Schema: &spec.Schema{
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Ref: ref("github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1.Unstructured"),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"targets": {
|
||||
VendorExtensible: spec.VendorExtensible{
|
||||
Extensions: spec.Extensions{
|
||||
@@ -886,7 +962,7 @@ func schema_pkg_apis_dashboard_v0alpha1_LibraryPanelSpec(ref common.ReferenceCal
|
||||
},
|
||||
},
|
||||
Dependencies: []string{
|
||||
"github.com/grafana/grafana-plugin-sdk-go/experimental/apis/data/v0alpha1.DataQuery", "github.com/grafana/grafana-plugin-sdk-go/experimental/apis/data/v0alpha1.DataSourceRef", "github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1.Unstructured"},
|
||||
"github.com/grafana/grafana-plugin-sdk-go/experimental/apis/data/v0alpha1.DataQuery", "github.com/grafana/grafana-plugin-sdk-go/experimental/apis/data/v0alpha1.DataSourceRef", "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v0alpha1.GridPos", "github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1.Unstructured"},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+1
@@ -1,6 +1,7 @@
|
||||
API rule violation: list_type_missing,github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v0alpha1,DashboardHit,Tags
|
||||
API rule violation: list_type_missing,github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v0alpha1,DashboardMetadata,Finalizers
|
||||
API rule violation: list_type_missing,github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v0alpha1,FacetResult,Terms
|
||||
API rule violation: list_type_missing,github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v0alpha1,LibraryPanelSpec,Links
|
||||
API rule violation: list_type_missing,github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v0alpha1,LibraryPanelStatus,Warnings
|
||||
API rule violation: list_type_missing,github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v0alpha1,SearchResults,Hits
|
||||
API rule violation: list_type_missing,github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v0alpha1,SortableFields,Fields
|
||||
|
||||
@@ -22,6 +22,7 @@ import (
|
||||
"github.com/grafana/grafana/pkg/infra/db"
|
||||
"github.com/grafana/grafana/pkg/infra/tracing"
|
||||
"github.com/grafana/grafana/pkg/registry/apis/dashboard/legacy"
|
||||
"github.com/grafana/grafana/pkg/services/accesscontrol/acimpl"
|
||||
"github.com/grafana/grafana/pkg/services/featuremgmt"
|
||||
"github.com/grafana/grafana/pkg/services/search/sort"
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
@@ -75,6 +76,7 @@ func ToUnifiedStorage(c utils.CommandLine, cfg *setting.Cfg, sqlStore db.DB) err
|
||||
provisioning,
|
||||
nil, // no librarypanels.Service
|
||||
sort.ProvideService(),
|
||||
acimpl.ProvideAccessControl(featuremgmt.WithFeatures()),
|
||||
)
|
||||
|
||||
client, err := newUnifiedClient(cfg, sqlStore)
|
||||
|
||||
@@ -6,10 +6,12 @@ import (
|
||||
"k8s.io/apiserver/pkg/authorization/authorizer"
|
||||
|
||||
"github.com/grafana/authlib/types"
|
||||
dashv0 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v0alpha1"
|
||||
"github.com/grafana/grafana/pkg/apimachinery/identity"
|
||||
"github.com/grafana/grafana/pkg/infra/log"
|
||||
"github.com/grafana/grafana/pkg/services/accesscontrol"
|
||||
"github.com/grafana/grafana/pkg/services/dashboards"
|
||||
"github.com/grafana/grafana/pkg/services/libraryelements"
|
||||
)
|
||||
|
||||
func GetAuthorizer(ac accesscontrol.AccessControl, l log.Logger) authorizer.Authorizer {
|
||||
@@ -42,38 +44,87 @@ func GetAuthorizer(ac accesscontrol.AccessControl, l log.Logger) authorizer.Auth
|
||||
return authorizer.DecisionDeny, "org mismatch", dashboards.ErrUserIsNotSignedInToOrg
|
||||
}
|
||||
|
||||
switch attr.GetVerb() {
|
||||
case "list", "search":
|
||||
// Detailed read permissions are handled by authz, this just checks whether the user can ready *any* dashboard
|
||||
ok, err := ac.Evaluate(ctx, user, accesscontrol.EvalPermission(dashboards.ActionDashboardsRead))
|
||||
if !ok || err != nil {
|
||||
return authorizer.DecisionDeny, "can not read any dashboards", err
|
||||
}
|
||||
case "create":
|
||||
// Detailed create permissions are handled by authz, this just checks whether the user can create *any* dashboard
|
||||
ok, err := ac.Evaluate(ctx, user, accesscontrol.EvalPermission(dashboards.ActionDashboardsCreate))
|
||||
if !ok || err != nil {
|
||||
return authorizer.DecisionDeny, "can not create any dashboards", err
|
||||
}
|
||||
case "get":
|
||||
ok, err := ac.Evaluate(ctx, user, accesscontrol.EvalPermission(dashboards.ActionDashboardsRead, dashboards.ScopeDashboardsProvider.GetResourceScopeUID(attr.GetName())))
|
||||
if !ok || err != nil {
|
||||
return authorizer.DecisionDeny, "can not view dashboard", err
|
||||
}
|
||||
case "update", "patch":
|
||||
ok, err := ac.Evaluate(ctx, user, accesscontrol.EvalPermission(dashboards.ActionDashboardsWrite, dashboards.ScopeDashboardsProvider.GetResourceScopeUID(attr.GetName())))
|
||||
if !ok || err != nil {
|
||||
return authorizer.DecisionDeny, "can not edit dashboard", err
|
||||
}
|
||||
case "delete":
|
||||
ok, err := ac.Evaluate(ctx, user, accesscontrol.EvalPermission(dashboards.ActionDashboardsDelete, dashboards.ScopeDashboardsProvider.GetResourceScopeUID(attr.GetName())))
|
||||
if !ok || err != nil {
|
||||
return authorizer.DecisionDeny, "can not delete dashboard", err
|
||||
}
|
||||
default:
|
||||
l.Info("unknown verb", "verb", attr.GetVerb())
|
||||
return authorizer.DecisionDeny, "unsupported verb", nil // Unknown verb
|
||||
// Determine if this is a library panel or dashboard resource
|
||||
resource := attr.GetResource()
|
||||
isLibraryPanel := resource == dashv0.LIBRARY_PANEL_RESOURCE
|
||||
|
||||
if isLibraryPanel {
|
||||
return authorizeLibraryPanel(ctx, ac, user, attr)
|
||||
} else {
|
||||
return authorizeDashboard(ctx, ac, user, attr)
|
||||
}
|
||||
return authorizer.DecisionAllow, "", nil
|
||||
})
|
||||
}
|
||||
|
||||
func authorizeLibraryPanel(ctx context.Context, ac accesscontrol.AccessControl, user identity.Requester, attr authorizer.Attributes) (authorizer.Decision, string, error) {
|
||||
switch attr.GetVerb() {
|
||||
case "list", "search":
|
||||
// Detailed read permissions are handled by authz, this just checks whether the user can ready *any* library panel
|
||||
ok, err := ac.Evaluate(ctx, user, accesscontrol.EvalPermission(libraryelements.ActionLibraryPanelsRead))
|
||||
if !ok || err != nil {
|
||||
return authorizer.DecisionDeny, "can not read any library panels", err
|
||||
}
|
||||
case "create":
|
||||
// TODO: uncomment this when we implement create :)
|
||||
//
|
||||
// Detailed create permissions are handled by authz, this just checks whether the user can create *any* library panel
|
||||
// ok, err := ac.Evaluate(ctx, user, accesscontrol.EvalPermission(libraryelements.ActionLibraryPanelsCreate))
|
||||
// if !ok || err != nil {
|
||||
// return authorizer.DecisionDeny, "can not create any library panels", err
|
||||
// }
|
||||
return authorizer.DecisionDeny, "can not create any library panels", nil
|
||||
case "get":
|
||||
ok, err := ac.Evaluate(ctx, user, accesscontrol.EvalPermission(libraryelements.ActionLibraryPanelsRead, libraryelements.ScopeLibraryPanelsProvider.GetResourceScopeUID(attr.GetName())))
|
||||
if !ok || err != nil {
|
||||
return authorizer.DecisionDeny, "can not view library panel", err
|
||||
}
|
||||
case "update", "patch":
|
||||
ok, err := ac.Evaluate(ctx, user, accesscontrol.EvalPermission(libraryelements.ActionLibraryPanelsWrite, libraryelements.ScopeLibraryPanelsProvider.GetResourceScopeUID(attr.GetName())))
|
||||
if !ok || err != nil {
|
||||
return authorizer.DecisionDeny, "can not edit library panel", err
|
||||
}
|
||||
case "delete":
|
||||
ok, err := ac.Evaluate(ctx, user, accesscontrol.EvalPermission(libraryelements.ActionLibraryPanelsDelete, libraryelements.ScopeLibraryPanelsProvider.GetResourceScopeUID(attr.GetName())))
|
||||
if !ok || err != nil {
|
||||
return authorizer.DecisionDeny, "can not delete library panel", err
|
||||
}
|
||||
default:
|
||||
return authorizer.DecisionDeny, "unsupported verb for library panels", nil
|
||||
}
|
||||
return authorizer.DecisionAllow, "", nil
|
||||
}
|
||||
|
||||
func authorizeDashboard(ctx context.Context, ac accesscontrol.AccessControl, user identity.Requester, attr authorizer.Attributes) (authorizer.Decision, string, error) {
|
||||
switch attr.GetVerb() {
|
||||
case "list", "search":
|
||||
// Detailed read permissions are handled by authz, this just checks whether the user can ready *any* dashboard
|
||||
ok, err := ac.Evaluate(ctx, user, accesscontrol.EvalPermission(dashboards.ActionDashboardsRead))
|
||||
if !ok || err != nil {
|
||||
return authorizer.DecisionDeny, "can not read any dashboards", err
|
||||
}
|
||||
case "create":
|
||||
// Detailed create permissions are handled by authz, this just checks whether the user can create *any* dashboard
|
||||
ok, err := ac.Evaluate(ctx, user, accesscontrol.EvalPermission(dashboards.ActionDashboardsCreate))
|
||||
if !ok || err != nil {
|
||||
return authorizer.DecisionDeny, "can not create any dashboards", err
|
||||
}
|
||||
case "get":
|
||||
ok, err := ac.Evaluate(ctx, user, accesscontrol.EvalPermission(dashboards.ActionDashboardsRead, dashboards.ScopeDashboardsProvider.GetResourceScopeUID(attr.GetName())))
|
||||
if !ok || err != nil {
|
||||
return authorizer.DecisionDeny, "can not view dashboard", err
|
||||
}
|
||||
case "update", "patch":
|
||||
ok, err := ac.Evaluate(ctx, user, accesscontrol.EvalPermission(dashboards.ActionDashboardsWrite, dashboards.ScopeDashboardsProvider.GetResourceScopeUID(attr.GetName())))
|
||||
if !ok || err != nil {
|
||||
return authorizer.DecisionDeny, "can not edit dashboard", err
|
||||
}
|
||||
case "delete":
|
||||
ok, err := ac.Evaluate(ctx, user, accesscontrol.EvalPermission(dashboards.ActionDashboardsDelete, dashboards.ScopeDashboardsProvider.GetResourceScopeUID(attr.GetName())))
|
||||
if !ok || err != nil {
|
||||
return authorizer.DecisionDeny, "can not delete dashboard", err
|
||||
}
|
||||
default:
|
||||
return authorizer.DecisionDeny, "unsupported verb for dashboards", nil
|
||||
}
|
||||
return authorizer.DecisionAllow, "", nil
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
folders "github.com/grafana/grafana/apps/folder/pkg/apis/folder/v1beta1"
|
||||
"github.com/grafana/grafana/pkg/apimachinery/utils"
|
||||
"github.com/grafana/grafana/pkg/infra/db"
|
||||
"github.com/grafana/grafana/pkg/services/accesscontrol"
|
||||
"github.com/grafana/grafana/pkg/services/librarypanels"
|
||||
"github.com/grafana/grafana/pkg/services/provisioning"
|
||||
"github.com/grafana/grafana/pkg/services/search/sort"
|
||||
@@ -48,9 +49,10 @@ func ProvideLegacyMigrator(
|
||||
sql db.DB, // direct access to tables
|
||||
provisioning provisioning.ProvisioningService, // only needed for dashboard settings
|
||||
libraryPanelSvc librarypanels.Service,
|
||||
accessControl accesscontrol.AccessControl,
|
||||
) LegacyMigrator {
|
||||
dbp := legacysql.NewDatabaseProvider(sql)
|
||||
return NewDashboardAccess(dbp, authlib.OrgNamespaceFormatter, nil, provisioning, libraryPanelSvc, sort.ProvideService())
|
||||
return NewDashboardAccess(dbp, authlib.OrgNamespaceFormatter, nil, provisioning, libraryPanelSvc, sort.ProvideService(), accessControl)
|
||||
}
|
||||
|
||||
type BlobStoreInfo struct {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
SELECT p.id, p.uid, p.folder_uid,
|
||||
p.created, created_user.uid as created_by,
|
||||
p.updated, updated_user.uid as updated_by,
|
||||
p.name, p.type, p.description, p.model
|
||||
p.name, p.type, p.description, p.model, p.version
|
||||
FROM {{ .Ident .LibraryElementTable }} as p
|
||||
LEFT OUTER JOIN {{ .Ident .UserTable }} AS created_user ON p.created_by = created_user.id
|
||||
LEFT OUTER JOIN {{ .Ident .UserTable }} AS updated_user ON p.updated_by = updated_user.id
|
||||
|
||||
@@ -25,9 +25,11 @@ import (
|
||||
"github.com/grafana/grafana/pkg/components/simplejson"
|
||||
"github.com/grafana/grafana/pkg/infra/log"
|
||||
"github.com/grafana/grafana/pkg/registry/apis/dashboard/legacysearcher"
|
||||
"github.com/grafana/grafana/pkg/services/accesscontrol"
|
||||
"github.com/grafana/grafana/pkg/services/apiserver/endpoints/request"
|
||||
gapiutil "github.com/grafana/grafana/pkg/services/apiserver/utils"
|
||||
"github.com/grafana/grafana/pkg/services/dashboards"
|
||||
"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/search/sort"
|
||||
@@ -64,6 +66,7 @@ type dashboardSqlAccess struct {
|
||||
dashStore dashboards.Store
|
||||
dashboardSearchClient legacysearcher.DashboardSearchClient
|
||||
|
||||
accessControl accesscontrol.AccessControl
|
||||
libraryPanelSvc librarypanels.Service
|
||||
|
||||
// Typically one... the server wrapper
|
||||
@@ -78,6 +81,7 @@ func NewDashboardAccess(sql legacysql.LegacyDatabaseProvider,
|
||||
provisioning provisioning.ProvisioningService,
|
||||
libraryPanelSvc librarypanels.Service,
|
||||
sorter sort.Service,
|
||||
accessControl accesscontrol.AccessControl,
|
||||
) DashboardAccess {
|
||||
dashboardSearchClient := legacysearcher.NewDashboardSearchClient(dashStore, sorter)
|
||||
return &dashboardSqlAccess{
|
||||
@@ -87,6 +91,7 @@ func NewDashboardAccess(sql legacysql.LegacyDatabaseProvider,
|
||||
provisioning: provisioning,
|
||||
dashboardSearchClient: *dashboardSearchClient,
|
||||
libraryPanelSvc: libraryPanelSvc,
|
||||
accessControl: accessControl,
|
||||
log: log.New("dashboard.legacysql"),
|
||||
}
|
||||
}
|
||||
@@ -478,6 +483,25 @@ func (a *dashboardSqlAccess) SaveDashboard(ctx context.Context, orgId int64, das
|
||||
return dash, created, err
|
||||
}
|
||||
|
||||
type panel struct {
|
||||
ID int64
|
||||
UID string
|
||||
FolderUID sql.NullString
|
||||
|
||||
Created time.Time
|
||||
CreatedBy string
|
||||
|
||||
Updated time.Time
|
||||
UpdatedBy string
|
||||
|
||||
Version int64
|
||||
|
||||
Name string
|
||||
Type string
|
||||
Description string
|
||||
Model []byte
|
||||
}
|
||||
|
||||
func (a *dashboardSqlAccess) GetLibraryPanels(ctx context.Context, query LibraryPanelQuery) (*dashboardV0.LibraryPanelList, error) {
|
||||
limit := int(query.Limit)
|
||||
query.Limit += 1 // for continue
|
||||
@@ -485,6 +509,11 @@ func (a *dashboardSqlAccess) GetLibraryPanels(ctx context.Context, query Library
|
||||
return nil, fmt.Errorf("expected non zero orgID")
|
||||
}
|
||||
|
||||
user, err := identity.GetRequester(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
sqlx, err := a.sql(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -508,93 +537,30 @@ func (a *dashboardSqlAccess) GetLibraryPanels(ctx context.Context, query Library
|
||||
return nil, err
|
||||
}
|
||||
|
||||
type panel struct {
|
||||
ID int64
|
||||
UID string
|
||||
FolderUID sql.NullString
|
||||
|
||||
Created time.Time
|
||||
CreatedBy string
|
||||
|
||||
Updated time.Time
|
||||
UpdatedBy string
|
||||
|
||||
Name string
|
||||
Type string
|
||||
Description string
|
||||
Model []byte
|
||||
}
|
||||
|
||||
var lastID int64
|
||||
for rows.Next() {
|
||||
p := panel{}
|
||||
err = rows.Scan(&p.ID, &p.UID, &p.FolderUID,
|
||||
&p.Created, &p.CreatedBy,
|
||||
&p.Updated, &p.UpdatedBy,
|
||||
&p.Name, &p.Type, &p.Description, &p.Model,
|
||||
&p.Name, &p.Type, &p.Description, &p.Model, &p.Version,
|
||||
)
|
||||
if err != nil {
|
||||
return res, err
|
||||
}
|
||||
lastID = p.ID
|
||||
|
||||
item := dashboardV0.LibraryPanel{
|
||||
TypeMeta: metav1.TypeMeta{
|
||||
APIVersion: dashboardV0.APIVERSION,
|
||||
Kind: "LibraryPanel",
|
||||
},
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: p.UID,
|
||||
CreationTimestamp: metav1.NewTime(p.Created),
|
||||
ResourceVersion: strconv.FormatInt(p.Updated.UnixMicro(), 10),
|
||||
},
|
||||
Spec: dashboardV0.LibraryPanelSpec{},
|
||||
}
|
||||
|
||||
status := &dashboardV0.LibraryPanelStatus{
|
||||
Missing: v0alpha1.Unstructured{},
|
||||
}
|
||||
err = json.Unmarshal(p.Model, &item.Spec)
|
||||
item, err := parseLibraryPanelRow(p)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
err = json.Unmarshal(p.Model, &status.Missing.Object)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return res, err
|
||||
}
|
||||
|
||||
if item.Spec.Title != p.Name {
|
||||
status.Warnings = append(status.Warnings, fmt.Sprintf("title mismatch (expected: %s)", p.Name))
|
||||
}
|
||||
if item.Spec.Description != p.Description {
|
||||
status.Warnings = append(status.Warnings, fmt.Sprintf("description mismatch (expected: %s)", p.Description))
|
||||
}
|
||||
if item.Spec.Type != p.Type {
|
||||
status.Warnings = append(status.Warnings, fmt.Sprintf("type mismatch (expected: %s)", p.Type))
|
||||
}
|
||||
item.Status = status
|
||||
|
||||
// Remove the properties we are already showing
|
||||
for _, k := range []string{"type", "pluginVersion", "title", "description", "options", "fieldConfig", "datasource", "targets", "libraryPanel"} {
|
||||
delete(status.Missing.Object, k)
|
||||
}
|
||||
|
||||
meta, err := utils.MetaAccessor(&item)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if p.FolderUID.Valid {
|
||||
meta.SetFolder(p.FolderUID.String)
|
||||
}
|
||||
meta.SetCreatedBy(p.CreatedBy)
|
||||
meta.SetGeneration(1)
|
||||
meta.SetDeprecatedInternalID(p.ID) //nolint:staticcheck
|
||||
|
||||
// Only set updated metadata if it is different
|
||||
if p.UpdatedBy != p.CreatedBy || p.Updated.Sub(p.Created) > time.Second {
|
||||
meta.SetUpdatedBy(p.UpdatedBy)
|
||||
meta.SetUpdatedTimestamp(&p.Updated)
|
||||
meta.SetGeneration(2)
|
||||
ok, err := a.accessControl.Evaluate(ctx, user, accesscontrol.EvalPermission(
|
||||
libraryelements.ActionLibraryPanelsRead,
|
||||
libraryelements.ScopeLibraryPanelsProvider.GetResourceScopeUID(item.Name),
|
||||
))
|
||||
if err != nil || !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
res.Items = append(res.Items, item)
|
||||
@@ -611,3 +577,71 @@ func (a *dashboardSqlAccess) GetLibraryPanels(ctx context.Context, query Library
|
||||
}
|
||||
return res, err
|
||||
}
|
||||
|
||||
func parseLibraryPanelRow(p panel) (dashboardV0.LibraryPanel, error) {
|
||||
item := dashboardV0.LibraryPanel{
|
||||
TypeMeta: metav1.TypeMeta{
|
||||
APIVersion: dashboardV0.APIVERSION,
|
||||
Kind: "LibraryPanel",
|
||||
},
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: p.UID,
|
||||
CreationTimestamp: metav1.NewTime(p.Created),
|
||||
ResourceVersion: strconv.FormatInt(p.Updated.UnixMicro(), 10),
|
||||
},
|
||||
Spec: dashboardV0.LibraryPanelSpec{},
|
||||
}
|
||||
|
||||
status := &dashboardV0.LibraryPanelStatus{
|
||||
Missing: v0alpha1.Unstructured{},
|
||||
}
|
||||
err := json.Unmarshal(p.Model, &item.Spec)
|
||||
if err != nil {
|
||||
return item, err
|
||||
}
|
||||
err = json.Unmarshal(p.Model, &status.Missing.Object)
|
||||
if err != nil {
|
||||
return item, err
|
||||
}
|
||||
|
||||
// the panel title used in dashboards and title of the library panel can differ
|
||||
// in the old model blob, the panel title is specified as "title", and the library panel title is
|
||||
// in "libraryPanel.name", or as the column in the db.
|
||||
item.Spec.PanelTitle = item.Spec.Title
|
||||
item.Spec.Title = p.Name
|
||||
|
||||
if item.Spec.Title != p.Name {
|
||||
status.Warnings = append(status.Warnings, fmt.Sprintf("title mismatch (expected: %s)", p.Name))
|
||||
}
|
||||
if item.Spec.Description != p.Description {
|
||||
status.Warnings = append(status.Warnings, fmt.Sprintf("description mismatch (expected: %s)", p.Description))
|
||||
}
|
||||
if item.Spec.Type != p.Type {
|
||||
status.Warnings = append(status.Warnings, fmt.Sprintf("type mismatch (expected: %s)", p.Type))
|
||||
}
|
||||
item.Status = status
|
||||
|
||||
// Remove the properties we are already showing
|
||||
for _, k := range []string{"type", "pluginVersion", "title", "description", "options", "fieldConfig", "datasource", "targets", "libraryPanel", "id", "gridPos"} {
|
||||
delete(status.Missing.Object, k)
|
||||
}
|
||||
|
||||
meta, err := utils.MetaAccessor(&item)
|
||||
if err != nil {
|
||||
return item, err
|
||||
}
|
||||
if p.FolderUID.Valid {
|
||||
meta.SetFolder(p.FolderUID.String)
|
||||
}
|
||||
meta.SetCreatedBy(p.CreatedBy)
|
||||
meta.SetGeneration(p.Version)
|
||||
meta.SetDeprecatedInternalID(p.ID) //nolint:staticcheck
|
||||
|
||||
// Only set updated metadata if it is different
|
||||
if p.UpdatedBy != p.CreatedBy || p.Updated.Sub(p.Created) > time.Second {
|
||||
meta.SetUpdatedBy(p.UpdatedBy)
|
||||
meta.SetUpdatedTimestamp(&p.Updated)
|
||||
}
|
||||
|
||||
return item, nil
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@ package legacy
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -221,3 +223,81 @@ func TestBuildSaveDashboardCommand(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseLibraryPanelRow(t *testing.T) {
|
||||
basePanel := panel{
|
||||
ID: 1,
|
||||
UID: "panel-uid",
|
||||
FolderUID: sql.NullString{String: "folder-uid", Valid: true},
|
||||
Created: time.Now(),
|
||||
CreatedBy: "creator",
|
||||
Updated: time.Now(),
|
||||
UpdatedBy: "updator",
|
||||
Version: 2,
|
||||
Type: "graph",
|
||||
Description: "desc from db",
|
||||
}
|
||||
|
||||
t.Run("type mismatch triggers warning", func(t *testing.T) {
|
||||
p := basePanel
|
||||
p.Name = "Test Panel"
|
||||
p.Type = "graph"
|
||||
model := map[string]interface{}{
|
||||
"title": "Test Panel",
|
||||
"type": "table",
|
||||
"description": "desc from db",
|
||||
}
|
||||
modelBytes, err := json.Marshal(model)
|
||||
require.NoError(t, err)
|
||||
p.Model = modelBytes
|
||||
|
||||
item, err := parseLibraryPanelRow(p)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "table", item.Spec.Type)
|
||||
require.NotEmpty(t, item.Status.Warnings)
|
||||
require.Contains(t, item.Status.Warnings[0], "type mismatch")
|
||||
})
|
||||
|
||||
t.Run("metadata is set correctly", func(t *testing.T) {
|
||||
p := basePanel
|
||||
p.Name = "Test Panel"
|
||||
model := map[string]interface{}{
|
||||
"title": "Test Panel",
|
||||
"type": "graph",
|
||||
"description": "desc from db",
|
||||
}
|
||||
modelBytes, err := json.Marshal(model)
|
||||
require.NoError(t, err)
|
||||
p.Model = modelBytes
|
||||
|
||||
item, err := parseLibraryPanelRow(p)
|
||||
require.NoError(t, err)
|
||||
|
||||
meta, err := utils.MetaAccessor(&item)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, p.ID, meta.GetDeprecatedInternalID()) // nolint:staticcheck
|
||||
require.Equal(t, p.Version, meta.GetGeneration())
|
||||
require.Equal(t, p.FolderUID.String, meta.GetFolder())
|
||||
require.Equal(t, p.CreatedBy, meta.GetCreatedBy())
|
||||
require.Equal(t, p.UpdatedBy, meta.GetUpdatedBy())
|
||||
})
|
||||
|
||||
t.Run("panel title in dashboard vs library panel title is set correctly", func(t *testing.T) {
|
||||
p := basePanel
|
||||
p.Name = "Database Name"
|
||||
model := map[string]interface{}{
|
||||
"title": "Model Title",
|
||||
"type": "graph",
|
||||
"description": "desc from db",
|
||||
}
|
||||
modelBytes, err := json.Marshal(model)
|
||||
require.NoError(t, err)
|
||||
p.Model = modelBytes
|
||||
|
||||
item, err := parseLibraryPanelRow(p)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Equal(t, "Model Title", item.Spec.PanelTitle)
|
||||
require.Equal(t, "Database Name", item.Spec.Title)
|
||||
})
|
||||
}
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
SELECT p.id, p.uid, p.folder_uid,
|
||||
p.created, created_user.uid as created_by,
|
||||
p.updated, updated_user.uid as updated_by,
|
||||
p.name, p.type, p.description, p.model
|
||||
p.name, p.type, p.description, p.model, p.version
|
||||
FROM `grafana`.`library_element` as p
|
||||
LEFT OUTER JOIN `grafana`.`user` AS created_user ON p.created_by = created_user.id
|
||||
LEFT OUTER JOIN `grafana`.`user` AS updated_user ON p.updated_by = updated_user.id
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
SELECT p.id, p.uid, p.folder_uid,
|
||||
p.created, created_user.uid as created_by,
|
||||
p.updated, updated_user.uid as updated_by,
|
||||
p.name, p.type, p.description, p.model
|
||||
p.name, p.type, p.description, p.model, p.version
|
||||
FROM `grafana`.`library_element` as p
|
||||
LEFT OUTER JOIN `grafana`.`user` AS created_user ON p.created_by = created_user.id
|
||||
LEFT OUTER JOIN `grafana`.`user` AS updated_user ON p.updated_by = updated_user.id
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
SELECT p.id, p.uid, p.folder_uid,
|
||||
p.created, created_user.uid as created_by,
|
||||
p.updated, updated_user.uid as updated_by,
|
||||
p.name, p.type, p.description, p.model
|
||||
p.name, p.type, p.description, p.model, p.version
|
||||
FROM `grafana`.`library_element` as p
|
||||
LEFT OUTER JOIN `grafana`.`user` AS created_user ON p.created_by = created_user.id
|
||||
LEFT OUTER JOIN `grafana`.`user` AS updated_user ON p.updated_by = updated_user.id
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
SELECT p.id, p.uid, p.folder_uid,
|
||||
p.created, created_user.uid as created_by,
|
||||
p.updated, updated_user.uid as updated_by,
|
||||
p.name, p.type, p.description, p.model
|
||||
p.name, p.type, p.description, p.model, p.version
|
||||
FROM "grafana"."library_element" as p
|
||||
LEFT OUTER JOIN "grafana"."user" AS created_user ON p.created_by = created_user.id
|
||||
LEFT OUTER JOIN "grafana"."user" AS updated_user ON p.updated_by = updated_user.id
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
SELECT p.id, p.uid, p.folder_uid,
|
||||
p.created, created_user.uid as created_by,
|
||||
p.updated, updated_user.uid as updated_by,
|
||||
p.name, p.type, p.description, p.model
|
||||
p.name, p.type, p.description, p.model, p.version
|
||||
FROM "grafana"."library_element" as p
|
||||
LEFT OUTER JOIN "grafana"."user" AS created_user ON p.created_by = created_user.id
|
||||
LEFT OUTER JOIN "grafana"."user" AS updated_user ON p.updated_by = updated_user.id
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
SELECT p.id, p.uid, p.folder_uid,
|
||||
p.created, created_user.uid as created_by,
|
||||
p.updated, updated_user.uid as updated_by,
|
||||
p.name, p.type, p.description, p.model
|
||||
p.name, p.type, p.description, p.model, p.version
|
||||
FROM "grafana"."library_element" as p
|
||||
LEFT OUTER JOIN "grafana"."user" AS created_user ON p.created_by = created_user.id
|
||||
LEFT OUTER JOIN "grafana"."user" AS updated_user ON p.updated_by = updated_user.id
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
SELECT p.id, p.uid, p.folder_uid,
|
||||
p.created, created_user.uid as created_by,
|
||||
p.updated, updated_user.uid as updated_by,
|
||||
p.name, p.type, p.description, p.model
|
||||
p.name, p.type, p.description, p.model, p.version
|
||||
FROM "grafana"."library_element" as p
|
||||
LEFT OUTER JOIN "grafana"."user" AS created_user ON p.created_by = created_user.id
|
||||
LEFT OUTER JOIN "grafana"."user" AS updated_user ON p.updated_by = updated_user.id
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
SELECT p.id, p.uid, p.folder_uid,
|
||||
p.created, created_user.uid as created_by,
|
||||
p.updated, updated_user.uid as updated_by,
|
||||
p.name, p.type, p.description, p.model
|
||||
p.name, p.type, p.description, p.model, p.version
|
||||
FROM "grafana"."library_element" as p
|
||||
LEFT OUTER JOIN "grafana"."user" AS created_user ON p.created_by = created_user.id
|
||||
LEFT OUTER JOIN "grafana"."user" AS updated_user ON p.updated_by = updated_user.id
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
SELECT p.id, p.uid, p.folder_uid,
|
||||
p.created, created_user.uid as created_by,
|
||||
p.updated, updated_user.uid as updated_by,
|
||||
p.name, p.type, p.description, p.model
|
||||
p.name, p.type, p.description, p.model, p.version
|
||||
FROM "grafana"."library_element" as p
|
||||
LEFT OUTER JOIN "grafana"."user" AS created_user ON p.created_by = created_user.id
|
||||
LEFT OUTER JOIN "grafana"."user" AS updated_user ON p.updated_by = updated_user.id
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
|
||||
"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"
|
||||
)
|
||||
@@ -21,12 +22,16 @@ var (
|
||||
_ rest.SingularNameProvider = (*LibraryPanelStore)(nil)
|
||||
_ rest.Getter = (*LibraryPanelStore)(nil)
|
||||
_ rest.Lister = (*LibraryPanelStore)(nil)
|
||||
_ rest.Creater = (*LibraryPanelStore)(nil)
|
||||
_ rest.GracefulDeleter = (*LibraryPanelStore)(nil)
|
||||
_ rest.Updater = (*LibraryPanelStore)(nil)
|
||||
_ rest.Storage = (*LibraryPanelStore)(nil)
|
||||
)
|
||||
|
||||
type LibraryPanelStore struct {
|
||||
Access legacy.DashboardAccess
|
||||
ResourceInfo utils.ResourceInfo
|
||||
Access legacy.DashboardAccess
|
||||
ResourceInfo utils.ResourceInfo
|
||||
AccessControl accesscontrol.AccessControl
|
||||
}
|
||||
|
||||
func (s *LibraryPanelStore) New() runtime.Object {
|
||||
@@ -51,6 +56,22 @@ func (s *LibraryPanelStore) ConvertToTable(ctx context.Context, object runtime.O
|
||||
return s.ResourceInfo.TableConverter().ConvertToTable(ctx, object, tableOptions)
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
|
||||
func (s *LibraryPanelStore) DeleteCollection(ctx context.Context, deleteValidation rest.ValidateObjectFunc, options *metav1.DeleteOptions, listOptions *internalversion.ListOptions) (runtime.Object, error) {
|
||||
return nil, apierrors.NewMethodNotSupported(s.ResourceInfo.GroupResource(), "deletecollection")
|
||||
}
|
||||
|
||||
func (s *LibraryPanelStore) List(ctx context.Context, options *internalversion.ListOptions) (runtime.Object, error) {
|
||||
if options.ResourceVersion != "" {
|
||||
return nil, apierrors.NewBadRequest("List with explicit resourceVersion is not supported with this storage backend")
|
||||
|
||||
@@ -142,7 +142,7 @@ func RegisterAPIService(
|
||||
folderClient: folderClient,
|
||||
|
||||
legacy: &DashboardStorage{
|
||||
Access: legacy.NewDashboardAccess(dbp, namespacer, dashStore, provisioning, libraryPanelSvc, sorter),
|
||||
Access: legacy.NewDashboardAccess(dbp, namespacer, dashStore, provisioning, libraryPanelSvc, sorter, accessControl),
|
||||
DashboardService: dashboardService,
|
||||
},
|
||||
reg: reg,
|
||||
@@ -519,9 +519,21 @@ func (b *DashboardsAPIBuilder) storageForVersion(
|
||||
|
||||
// Expose read only library panels
|
||||
if libraryPanels != nil {
|
||||
storage[libraryPanels.StoragePath()] = &LibraryPanelStore{
|
||||
Access: b.legacy.Access,
|
||||
ResourceInfo: *libraryPanels,
|
||||
legacyLibraryStore := &LibraryPanelStore{
|
||||
Access: b.legacy.Access,
|
||||
ResourceInfo: *libraryPanels,
|
||||
AccessControl: b.accessControl,
|
||||
}
|
||||
|
||||
unifiedLibraryStore, err := grafanaregistry.NewRegistryStore(opts.Scheme, *libraryPanels, opts.OptsGetter)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
libraryGr := libraryPanels.GroupResource()
|
||||
storage[libraryPanels.StoragePath()], err = opts.DualWriteBuilder(libraryGr, legacyLibraryStore, unifiedLibraryStore)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -438,7 +438,7 @@ func TestIntegrationNestedFolderServiceBasicOperations(t *testing.T) {
|
||||
alertStore, err := ngstore.ProvideDBStore(cfg, featuresFlagOn, db, serviceWithFlagOn, dashSrv, ac, b)
|
||||
require.NoError(t, err)
|
||||
|
||||
elementService := libraryelements.ProvideService(cfg, db, routeRegister, serviceWithFlagOn, featuresFlagOn, ac, dashSrv)
|
||||
elementService := libraryelements.ProvideService(cfg, db, routeRegister, serviceWithFlagOn, featuresFlagOn, ac, dashSrv, nil, nil)
|
||||
lps, err := librarypanels.ProvideService(cfg, db, routeRegister, elementService, serviceWithFlagOn)
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -517,7 +517,7 @@ func TestIntegrationNestedFolderServiceBasicOperations(t *testing.T) {
|
||||
alertStore, err := ngstore.ProvideDBStore(cfg, featuresFlagOff, db, serviceWithFlagOff, dashSrv, ac, b)
|
||||
require.NoError(t, err)
|
||||
|
||||
elementService := libraryelements.ProvideService(cfg, db, routeRegister, serviceWithFlagOff, featuresFlagOff, ac, dashSrv)
|
||||
elementService := libraryelements.ProvideService(cfg, db, routeRegister, serviceWithFlagOff, featuresFlagOff, ac, dashSrv, nil, nil)
|
||||
lps, err := librarypanels.ProvideService(cfg, db, routeRegister, elementService, serviceWithFlagOff)
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -655,7 +655,7 @@ func TestIntegrationNestedFolderServiceBasicOperations(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
dashSrv.RegisterDashboardPermissions(dashboardPermissions)
|
||||
|
||||
elementService := libraryelements.ProvideService(cfg, db, routeRegister, tc.service, tc.featuresFlag, ac, dashSrv)
|
||||
elementService := libraryelements.ProvideService(cfg, db, routeRegister, tc.service, tc.featuresFlag, ac, dashSrv, nil, nil)
|
||||
lps, err := librarypanels.ProvideService(cfg, db, routeRegister, elementService, tc.service)
|
||||
require.NoError(t, err)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -850,7 +850,7 @@ func testScenario(t *testing.T, desc string, fn func(t *testing.T, sc scenarioCo
|
||||
fStore, ac, bus.ProvideBus(tracing.InitializeTracerForTest()), dashboardStore, folderStore,
|
||||
nil, sqlStore, features, supportbundlestest.NewFakeBundleService(), nil, cfg, nil, tracing.InitializeTracerForTest(), nil, dualwrite.ProvideTestService(), sort.ProvideService(), apiserver.WithoutRestConfig)
|
||||
|
||||
elementService := libraryelements.ProvideService(cfg, sqlStore, routing.NewRouteRegister(), folderService, features, ac, dashService)
|
||||
elementService := libraryelements.ProvideService(cfg, sqlStore, routing.NewRouteRegister(), folderService, features, ac, dashService, nil, nil)
|
||||
service := LibraryPanelService{
|
||||
Cfg: cfg,
|
||||
SQLStore: sqlStore,
|
||||
|
||||
@@ -2419,68 +2419,6 @@ func runDashboardListTest(t *testing.T, ctx TestContext) {
|
||||
})
|
||||
}
|
||||
|
||||
// TODO: this only works on mode0-3 right now. In modes 4/5, we need to start returning the connections endpoint
|
||||
// from retrieving the panel count from search / indexing the dashboard library panels
|
||||
func TestDashboardWithLibraryPanel(t *testing.T) {
|
||||
dualWriterModes := []rest.DualWriterMode{rest.Mode0, rest.Mode1, rest.Mode2, rest.Mode3}
|
||||
for _, dualWriterMode := range dualWriterModes {
|
||||
t.Run(fmt.Sprintf("DualWriterMode %d", dualWriterMode), func(t *testing.T) {
|
||||
helper := apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{
|
||||
DisableAnonymous: true,
|
||||
EnableFeatureToggles: []string{
|
||||
"unifiedStorageSearch",
|
||||
"kubernetesClientDashboardsFolders",
|
||||
},
|
||||
})
|
||||
ctx := createTestContext(t, helper, helper.Org1, dualWriterMode)
|
||||
adminClient := getResourceClient(t, ctx.Helper, ctx.AdminUser, getDashboardGVR())
|
||||
|
||||
// create the library element first
|
||||
libraryElement := map[string]interface{}{
|
||||
"kind": 1,
|
||||
"name": "Test Library Panel",
|
||||
"model": map[string]interface{}{
|
||||
"type": "timeseries",
|
||||
"title": "Test Library Panel",
|
||||
},
|
||||
}
|
||||
libraryElementURL := "/api/library-elements"
|
||||
libraryElementData, err := postHelper(t, &ctx, libraryElementURL, libraryElement, ctx.AdminUser)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, libraryElementData)
|
||||
data := libraryElementData["result"].(map[string]interface{})
|
||||
uid := data["uid"].(string)
|
||||
require.NotEmpty(t, uid)
|
||||
|
||||
// then reference the library element in the dashboard
|
||||
dashboard := createDashboardObject(t, "Library Panel Test", "", 1)
|
||||
dashboard.Object["spec"].(map[string]interface{})["panels"] = []interface{}{
|
||||
map[string]interface{}{
|
||||
"id": 1,
|
||||
"title": "Library Panel",
|
||||
"type": "library-panel-ref",
|
||||
"libraryPanel": map[string]interface{}{
|
||||
"uid": uid,
|
||||
"name": "Test Library Panel",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
createdDash, err := adminClient.Resource.Create(context.Background(), dashboard, v1.CreateOptions{})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, createdDash)
|
||||
|
||||
// should have created a library panel connection
|
||||
connectionsURL := fmt.Sprintf("/api/library-elements/%s/connections", uid)
|
||||
connectionsData, err := getDashboardViaHTTP(t, &ctx, connectionsURL, ctx.AdminUser)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, connectionsData)
|
||||
connections := connectionsData["result"].([]interface{})
|
||||
require.Len(t, connections, 1)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func postHelper(t *testing.T, ctx *TestContext, path string, body interface{}, user apis.User) (map[string]interface{}, error) {
|
||||
bodyJSON, err := json.Marshal(body)
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -0,0 +1,288 @@
|
||||
package integration
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
dashboardV0 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v0alpha1"
|
||||
"github.com/stretchr/testify/require"
|
||||
v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/runtime/schema"
|
||||
|
||||
"github.com/grafana/grafana/pkg/apiserver/rest"
|
||||
"github.com/grafana/grafana/pkg/tests/apis"
|
||||
"github.com/grafana/grafana/pkg/tests/testinfra"
|
||||
)
|
||||
|
||||
// this tests the /api path still, but behind the scenes is using search to get the library connections
|
||||
// as in modes 3+, the connections are found via searching dashboards for the reference of the library panel
|
||||
//
|
||||
// it also ensures we create the connection in modes 0-2 if a dashboard v1 is created with a reference
|
||||
func TestIntegrationLibraryPanelConnections(t *testing.T) {
|
||||
dualWriterModes := []rest.DualWriterMode{rest.Mode0, rest.Mode1, rest.Mode2, rest.Mode3, rest.Mode4, rest.Mode5}
|
||||
for _, dualWriterMode := range dualWriterModes {
|
||||
t.Run(fmt.Sprintf("DualWriterMode %d", dualWriterMode), func(t *testing.T) {
|
||||
helper := apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{
|
||||
DisableAnonymous: true,
|
||||
EnableFeatureToggles: []string{
|
||||
"unifiedStorageSearch",
|
||||
"kubernetesClientDashboardsFolders",
|
||||
"kubernetesLibraryPanels",
|
||||
},
|
||||
})
|
||||
ctx := createTestContext(t, helper, helper.Org1, dualWriterMode)
|
||||
adminClient := getResourceClient(t, ctx.Helper, ctx.AdminUser, getDashboardGVR())
|
||||
|
||||
// create the library element first
|
||||
libraryElement := map[string]interface{}{
|
||||
"kind": 1,
|
||||
"name": "Test Library Panel",
|
||||
"model": map[string]interface{}{
|
||||
"type": "timeseries",
|
||||
"title": "Test Library Panel",
|
||||
},
|
||||
}
|
||||
libraryElementURL := "/api/library-elements"
|
||||
libraryElementData, err := postHelper(t, &ctx, libraryElementURL, libraryElement, ctx.AdminUser)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, libraryElementData)
|
||||
data := libraryElementData["result"].(map[string]interface{})
|
||||
uid := data["uid"].(string)
|
||||
require.NotEmpty(t, uid)
|
||||
|
||||
// then reference the library element in the dashboard
|
||||
dashboard := createDashboardObject(t, "Library Panel Test", "", 1)
|
||||
dashboard.Object["spec"].(map[string]interface{})["panels"] = []interface{}{
|
||||
map[string]interface{}{
|
||||
"id": 1,
|
||||
"title": "Library Panel",
|
||||
"type": "library-panel-ref",
|
||||
"libraryPanel": map[string]interface{}{
|
||||
"uid": uid,
|
||||
"name": "Test Library Panel",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
createdDash, err := adminClient.Resource.Create(context.Background(), dashboard, v1.CreateOptions{})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, createdDash)
|
||||
|
||||
// should have created a library panel connection
|
||||
connectionsURL := fmt.Sprintf("/api/library-elements/%s/connections", uid)
|
||||
connectionsData, err := getDashboardViaHTTP(t, &ctx, connectionsURL, ctx.AdminUser)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, connectionsData)
|
||||
connections := connectionsData["result"].([]interface{})
|
||||
require.Len(t, connections, 1)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// this tests the /apis path to ensure authorization is being enforced. /api integration tests are within the service package
|
||||
// only works in modes 0-2 because the library element is created through the /api path
|
||||
func TestIntegrationLibraryElementPermissions(t *testing.T) {
|
||||
dualWriterModes := []rest.DualWriterMode{rest.Mode0, rest.Mode1, rest.Mode2}
|
||||
for _, dualWriterMode := range dualWriterModes {
|
||||
t.Run(fmt.Sprintf("DualWriterMode %d", dualWriterMode), func(t *testing.T) {
|
||||
helper := apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{
|
||||
DisableAnonymous: true,
|
||||
EnableFeatureToggles: []string{
|
||||
"unifiedStorageSearch",
|
||||
"kubernetesClientDashboardsFolders",
|
||||
"kubernetesLibraryPanels",
|
||||
"grafanaAPIServerWithExperimentalAPIs", // needed until we move it to v0beta1 at least (currently v0alpha1)
|
||||
},
|
||||
})
|
||||
ctx := createTestContext(t, helper, helper.Org1, dualWriterMode)
|
||||
|
||||
t.Run("Library element authorization tests", func(t *testing.T) {
|
||||
runLibraryElementAuthorizationTests(t, ctx)
|
||||
})
|
||||
|
||||
t.Run("Library element cross-organization tests", func(t *testing.T) {
|
||||
org2Ctx := createTestContext(t, helper, helper.OrgB, dualWriterMode)
|
||||
runLibraryElementCrossOrgTests(t, ctx, org2Ctx)
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func runLibraryElementAuthorizationTests(t *testing.T, ctx TestContext) {
|
||||
t.Helper()
|
||||
|
||||
roles := []string{"Admin", "Editor", "Viewer"}
|
||||
|
||||
for _, role := range roles {
|
||||
t.Run(role, func(t *testing.T) {
|
||||
var client *apis.K8sResourceClient
|
||||
switch role {
|
||||
case "Admin":
|
||||
client = getResourceClient(t, ctx.Helper, ctx.AdminUser, getLibraryElementGVR())
|
||||
case "Editor":
|
||||
client = getResourceClient(t, ctx.Helper, ctx.EditorUser, getLibraryElementGVR())
|
||||
case "Viewer":
|
||||
client = getResourceClient(t, ctx.Helper, ctx.ViewerUser, getLibraryElementGVR())
|
||||
}
|
||||
|
||||
t.Run("library element viewing", func(t *testing.T) {
|
||||
uid, err := createLibraryElement(t, ctx, ctx.AdminUser, "Library Element for "+role+" to view", "", nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
viewedLibElement, err := client.Resource.Get(context.Background(), uid, v1.GetOptions{})
|
||||
require.NoError(t, err, "All identities should be able to view library elements")
|
||||
require.NotNil(t, viewedLibElement)
|
||||
|
||||
err = deleteLibraryElement(t, ctx, ctx.AdminUser, uid)
|
||||
require.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("library element listing", func(t *testing.T) {
|
||||
uid, err := createLibraryElement(t, ctx, ctx.AdminUser, "Library Element for "+role+" to list", "", nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
listOpts := v1.ListOptions{}
|
||||
libElementList, err := client.Resource.List(context.Background(), listOpts)
|
||||
require.NoError(t, err, "All identities should be able to list library elements")
|
||||
require.NotNil(t, libElementList)
|
||||
require.Len(t, libElementList.Items, 1)
|
||||
require.Equal(t, uid, libElementList.Items[0].GetName())
|
||||
|
||||
err = deleteLibraryElement(t, ctx, ctx.AdminUser, uid)
|
||||
require.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("library element in a folder only an admin can see", func(t *testing.T) {
|
||||
restrictedFolder, err := createFolder(t, ctx.Helper, ctx.AdminUser, "Restricted Folder for "+role)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, restrictedFolder)
|
||||
uid, err := createLibraryElement(t, ctx, ctx.AdminUser, "Library Element in restricted folder", restrictedFolder.UID, nil)
|
||||
require.NoError(t, err)
|
||||
setResourceUserPermission(t, ctx, ctx.AdminUser, false, restrictedFolder.UID, []ResourcePermissionSetting{})
|
||||
|
||||
if role == "Admin" {
|
||||
_, err = client.Resource.Get(context.Background(), uid, v1.GetOptions{})
|
||||
require.NoError(t, err, "Admin should be able to access library element in restricted folder")
|
||||
} else {
|
||||
_, err = client.Resource.Get(context.Background(), uid, v1.GetOptions{})
|
||||
require.Error(t, err, "Should not be able to access library element in restricted folder")
|
||||
}
|
||||
|
||||
err = deleteLibraryElement(t, ctx, ctx.AdminUser, uid)
|
||||
require.NoError(t, err)
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func runLibraryElementCrossOrgTests(t *testing.T, org1Ctx, org2Ctx TestContext) {
|
||||
// org1 trying to access org2
|
||||
org1CrossEditorClient := org2Ctx.Helper.GetResourceClient(apis.ResourceClientArgs{
|
||||
User: org1Ctx.EditorUser,
|
||||
Namespace: org2Ctx.Helper.Namespacer(org2Ctx.OrgID),
|
||||
GVR: getLibraryElementGVR(),
|
||||
})
|
||||
org1CrossViewerClient := org2Ctx.Helper.GetResourceClient(apis.ResourceClientArgs{
|
||||
User: org1Ctx.ViewerUser,
|
||||
Namespace: org2Ctx.Helper.Namespacer(org2Ctx.OrgID),
|
||||
GVR: getLibraryElementGVR(),
|
||||
})
|
||||
|
||||
// org2 trying to access org1
|
||||
org2CrossEditorClient := org1Ctx.Helper.GetResourceClient(apis.ResourceClientArgs{
|
||||
User: org2Ctx.EditorUser,
|
||||
Namespace: org1Ctx.Helper.Namespacer(org1Ctx.OrgID),
|
||||
GVR: getLibraryElementGVR(),
|
||||
})
|
||||
org2CrossViewerClient := org1Ctx.Helper.GetResourceClient(apis.ResourceClientArgs{
|
||||
User: org2Ctx.ViewerUser,
|
||||
Namespace: org1Ctx.Helper.Namespacer(org1Ctx.OrgID),
|
||||
GVR: getLibraryElementGVR(),
|
||||
})
|
||||
|
||||
t.Run("Cross-organization access", func(t *testing.T) {
|
||||
org1LibElementUID, err := createLibraryElement(t, org1Ctx, org1Ctx.AdminUser, "Org1 Library Element", "", nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
org2LibElementUID, err := createLibraryElement(t, org2Ctx, org2Ctx.AdminUser, "Org2 Library Element", "", nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
defer func() {
|
||||
err = deleteLibraryElement(t, org1Ctx, org1Ctx.AdminUser, org1LibElementUID)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = deleteLibraryElement(t, org2Ctx, org2Ctx.AdminUser, org2LibElementUID)
|
||||
require.NoError(t, err)
|
||||
}()
|
||||
|
||||
testCrossOrgAccess := func(client *apis.K8sResourceClient, targetLibElementUID string, description string) {
|
||||
t.Run(description, func(t *testing.T) {
|
||||
_, err := client.Resource.Get(context.Background(), targetLibElementUID, v1.GetOptions{})
|
||||
require.Error(t, err, "Should not be able to access library element from another org")
|
||||
})
|
||||
}
|
||||
|
||||
testCrossOrgAccess(org1CrossEditorClient, org2LibElementUID, "Org1 editor cannot access Org2 library element")
|
||||
testCrossOrgAccess(org1CrossViewerClient, org2LibElementUID, "Org1 viewer cannot access Org2 library element")
|
||||
|
||||
testCrossOrgAccess(org2CrossEditorClient, org1LibElementUID, "Org2 editor cannot access Org1 library element")
|
||||
testCrossOrgAccess(org2CrossViewerClient, org1LibElementUID, "Org2 viewer cannot access Org1 library element")
|
||||
})
|
||||
}
|
||||
|
||||
func getLibraryElementGVR() schema.GroupVersionResource {
|
||||
return schema.GroupVersionResource{
|
||||
Group: dashboardV0.APIGroup,
|
||||
Version: dashboardV0.VERSION,
|
||||
Resource: dashboardV0.LIBRARY_PANEL_RESOURCE,
|
||||
}
|
||||
}
|
||||
|
||||
// currently through /api
|
||||
func createLibraryElement(t *testing.T, ctx TestContext, user apis.User, title string, folderUID string, uid *string) (string, error) {
|
||||
t.Helper()
|
||||
libraryElement := map[string]interface{}{
|
||||
"kind": 1,
|
||||
"name": title,
|
||||
"model": map[string]interface{}{
|
||||
"type": "text",
|
||||
"title": title,
|
||||
},
|
||||
}
|
||||
if folderUID != "" {
|
||||
libraryElement["folderUid"] = folderUID
|
||||
}
|
||||
|
||||
libraryElementURL := "/api/library-elements"
|
||||
libraryElementData, err := postHelper(t, &ctx, libraryElementURL, libraryElement, user)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
require.NotNil(t, libraryElementData)
|
||||
data := libraryElementData["result"].(map[string]interface{})
|
||||
uidStr := data["uid"].(string)
|
||||
require.NotEmpty(t, uidStr)
|
||||
|
||||
return uidStr, nil
|
||||
}
|
||||
|
||||
// currently through /api
|
||||
func deleteLibraryElement(t *testing.T, ctx TestContext, user apis.User, uid string) error {
|
||||
t.Helper()
|
||||
deleteURL := fmt.Sprintf("/api/library-elements/%s", uid)
|
||||
|
||||
resp := apis.DoRequest(ctx.Helper, apis.RequestParams{
|
||||
User: user,
|
||||
Method: http.MethodDelete,
|
||||
Path: deleteURL,
|
||||
}, &struct{}{})
|
||||
|
||||
if resp.Response.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("failed to delete library element: %s", resp.Response.Status)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user