K8s: Add dashboard service (requires dev mode) (#78565)

This commit is contained in:
Ryan McKinley 2024-01-10 15:20:30 -08:00 committed by GitHub
parent be12d3919f
commit 2c09f969f1
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
27 changed files with 4965 additions and 131 deletions

View File

@ -14,6 +14,7 @@ import (
"github.com/grafana/grafana/pkg/api/apierrors"
"github.com/grafana/grafana/pkg/api/dtos"
"github.com/grafana/grafana/pkg/api/response"
dashboardsV0 "github.com/grafana/grafana/pkg/apis/dashboard/v0alpha1"
"github.com/grafana/grafana/pkg/components/dashdiffs"
"github.com/grafana/grafana/pkg/components/simplejson"
"github.com/grafana/grafana/pkg/infra/metrics"
@ -141,7 +142,7 @@ func (hs *HTTPServer) GetDashboard(c *contextmodel.ReqContext) response.Response
creator = hs.getUserLogin(c.Req.Context(), dash.CreatedBy)
}
annotationPermissions := &dtos.AnnotationPermission{}
annotationPermissions := &dashboardsV0.AnnotationPermission{}
if hs.Features.IsEnabled(c.Req.Context(), featuremgmt.FlagAnnotationPermissionUpdate) {
hs.getAnnotationPermissionsByScope(c, &annotationPermissions.Dashboard, dashboards.ScopeDashboardsProvider.GetResourceScopeUID(dash.UID))
} else {
@ -223,7 +224,7 @@ func (hs *HTTPServer) GetDashboard(c *contextmodel.ReqContext) response.Response
return response.JSON(http.StatusOK, dto)
}
func (hs *HTTPServer) getAnnotationPermissionsByScope(c *contextmodel.ReqContext, actions *dtos.AnnotationActions, scope string) {
func (hs *HTTPServer) getAnnotationPermissionsByScope(c *contextmodel.ReqContext, actions *dashboardsV0.AnnotationActions, scope string) {
var err error
evaluate := accesscontrol.EvalPermission(accesscontrol.ActionAnnotationsCreate, scope)

View File

@ -3,6 +3,7 @@ package dtos
import (
"time"
dashboardsV0 "github.com/grafana/grafana/pkg/apis/dashboard/v0alpha1"
"github.com/grafana/grafana/pkg/components/simplejson"
)
@ -26,25 +27,15 @@ type DashboardMeta struct {
HasACL bool `json:"hasAcl" xorm:"has_acl"`
IsFolder bool `json:"isFolder"`
// Deprecated: use FolderUID instead
FolderId int64 `json:"folderId"`
FolderUid string `json:"folderUid"`
FolderTitle string `json:"folderTitle"`
FolderUrl string `json:"folderUrl"`
Provisioned bool `json:"provisioned"`
ProvisionedExternalId string `json:"provisionedExternalId"`
AnnotationsPermissions *AnnotationPermission `json:"annotationsPermissions"`
PublicDashboardUID string `json:"publicDashboardUid,omitempty"`
PublicDashboardEnabled bool `json:"publicDashboardEnabled,omitempty"`
}
type AnnotationPermission struct {
Dashboard AnnotationActions `json:"dashboard"`
Organization AnnotationActions `json:"organization"`
}
type AnnotationActions struct {
CanAdd bool `json:"canAdd"`
CanEdit bool `json:"canEdit"`
CanDelete bool `json:"canDelete"`
FolderId int64 `json:"folderId"`
FolderUid string `json:"folderUid"`
FolderTitle string `json:"folderTitle"`
FolderUrl string `json:"folderUrl"`
Provisioned bool `json:"provisioned"`
ProvisionedExternalId string `json:"provisionedExternalId"`
AnnotationsPermissions *dashboardsV0.AnnotationPermission `json:"annotationsPermissions"`
PublicDashboardUID string `json:"publicDashboardUid,omitempty"`
PublicDashboardEnabled bool `json:"publicDashboardEnabled,omitempty"`
}
type DashboardFullWithMeta struct {

View File

@ -0,0 +1,5 @@
// +k8s:deepcopy-gen=package
// +k8s:openapi-gen=true
// +groupName=dashboard.grafana.app
package v0alpha1 // import "github.com/grafana/grafana/pkg/apis/dashboard/v0alpha1"

View File

@ -0,0 +1,124 @@
package v0alpha1
import (
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
runtime "k8s.io/apimachinery/pkg/runtime"
"github.com/grafana/grafana/pkg/apis"
)
const (
GROUP = "dashboard.grafana.app"
VERSION = "v0alpha1"
APIVERSION = GROUP + "/" + VERSION
)
var DashboardResourceInfo = apis.NewResourceInfo(GROUP, VERSION,
"dashboards", "dashboard", "Dashboard",
func() runtime.Object { return &Dashboard{} },
func() runtime.Object { return &DashboardList{} },
)
var DashboardSummaryResourceInfo = apis.NewResourceInfo(GROUP, VERSION,
"summary", "summary", "DashboardSummary",
func() runtime.Object { return &DashboardSummary{} },
func() runtime.Object { return &DashboardSummaryList{} },
)
// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object
type Dashboard 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"`
// The dashboard body (unstructured for now)
Spec Unstructured `json:"spec"`
}
// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object
type DashboardList struct {
metav1.TypeMeta `json:",inline"`
// +optional
metav1.ListMeta `json:"metadata,omitempty"`
Items []Dashboard `json:"items,omitempty"`
}
// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object
type DashboardSummary struct {
metav1.TypeMeta `json:",inline"`
metav1.ObjectMeta `json:"metadata,omitempty"`
// The dashboard body
Spec DashboardSummarySpec `json:"spec,omitempty"`
}
type DashboardSummarySpec struct {
Title string `json:"title"`
Tags []string `json:"tags,omitempty"`
}
// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object
type DashboardSummaryList struct {
metav1.TypeMeta `json:",inline"`
// +optional
metav1.ListMeta `json:"metadata,omitempty"`
Items []DashboardSummary `json:"items,omitempty"`
}
// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object
type DashboardVersionsInfo struct {
metav1.TypeMeta `json:",inline"`
// +optional
metav1.ListMeta `json:"metadata,omitempty"`
Items []DashboardVersionInfo `json:"items,omitempty"`
}
type DashboardVersionInfo struct {
Version int `json:"version"`
ParentVersion int `json:"parentVersion,omitempty"`
Created int64 `json:"created"`
Message string `json:"message,omitempty"`
CreatedBy string `json:"createdBy,omitempty"`
}
// +k8s:conversion-gen:explicit-from=net/url.Values
// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object
type VersionsQueryOptions struct {
metav1.TypeMeta `json:",inline"`
// Path is the URL path
// +optional
Path string `json:"path,omitempty"`
// +optional
Version int64 `json:"version,omitempty"`
}
// Information about how the requesting user can use a given dashboard
// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object
type DashboardAccessInfo struct {
metav1.TypeMeta `json:",inline"`
CanSave bool `json:"canSave"`
CanEdit bool `json:"canEdit"`
CanAdmin bool `json:"canAdmin"`
CanStar bool `json:"canStar"`
CanDelete bool `json:"canDelete"`
AnnotationsPermissions *AnnotationPermission `json:"annotationsPermissions"`
}
type AnnotationPermission struct {
Dashboard AnnotationActions `json:"dashboard"`
Organization AnnotationActions `json:"organization"`
}
type AnnotationActions struct {
CanAdd bool `json:"canAdd"`
CanEdit bool `json:"canEdit"`
CanDelete bool `json:"canDelete"`
}

View File

@ -0,0 +1,100 @@
package v0alpha1
import (
"encoding/json"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
runtime "k8s.io/apimachinery/pkg/runtime"
)
// Unstructured allows objects that do not have Golang structs registered to be manipulated
// generically.
type Unstructured struct {
// Object is a JSON compatible map with string, float, int, bool, []interface{}, or
// map[string]interface{}
// children.
Object map[string]interface{}
}
func (u *Unstructured) UnstructuredContent() map[string]interface{} {
if u.Object == nil {
return make(map[string]interface{})
}
return u.Object
}
func (u *Unstructured) SetUnstructuredContent(content map[string]interface{}) {
u.Object = content
}
// MarshalJSON ensures that the unstructured object produces proper
// JSON when passed to Go's standard JSON library.
func (u *Unstructured) MarshalJSON() ([]byte, error) {
return json.Marshal(u.Object)
}
// UnmarshalJSON ensures that the unstructured object properly decodes
// JSON when passed to Go's standard JSON library.
func (u *Unstructured) UnmarshalJSON(b []byte) error {
return json.Unmarshal(b, &u.Object)
}
func (u *Unstructured) DeepCopy() *Unstructured {
if u == nil {
return nil
}
out := new(Unstructured)
*out = *u
out.Object = runtime.DeepCopyJSON(u.Object)
return out
}
func (u *Unstructured) DeepCopyInto(out *Unstructured) {
clone := u.DeepCopy()
*out = *clone
}
func (u *Unstructured) Set(field string, value interface{}) {
if u.Object == nil {
u.Object = make(map[string]interface{})
}
_ = unstructured.SetNestedField(u.Object, value, field)
}
func (u *Unstructured) Remove(fields ...string) {
if u.Object == nil {
u.Object = make(map[string]interface{})
}
unstructured.RemoveNestedField(u.Object, fields...)
}
func (u *Unstructured) SetNestedField(value interface{}, fields ...string) {
if u.Object == nil {
u.Object = make(map[string]interface{})
}
_ = unstructured.SetNestedField(u.Object, value, fields...)
}
func (u *Unstructured) GetNestedString(fields ...string) string {
val, found, err := unstructured.NestedString(u.Object, fields...)
if !found || err != nil {
return ""
}
return val
}
func (u *Unstructured) GetNestedStringSlice(fields ...string) []string {
val, found, err := unstructured.NestedStringSlice(u.Object, fields...)
if !found || err != nil {
return nil
}
return val
}
func (u *Unstructured) GetNestedInt64(fields ...string) int64 {
val, found, err := unstructured.NestedInt64(u.Object, fields...)
if !found || err != nil {
return 0
}
return val
}

View File

@ -0,0 +1,289 @@
//go:build !ignore_autogenerated
// +build !ignore_autogenerated
// SPDX-License-Identifier: AGPL-3.0-only
// Code generated by deepcopy-gen. DO NOT EDIT.
package v0alpha1
import (
runtime "k8s.io/apimachinery/pkg/runtime"
)
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *AnnotationActions) DeepCopyInto(out *AnnotationActions) {
*out = *in
return
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new AnnotationActions.
func (in *AnnotationActions) DeepCopy() *AnnotationActions {
if in == nil {
return nil
}
out := new(AnnotationActions)
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *AnnotationPermission) DeepCopyInto(out *AnnotationPermission) {
*out = *in
out.Dashboard = in.Dashboard
out.Organization = in.Organization
return
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new AnnotationPermission.
func (in *AnnotationPermission) DeepCopy() *AnnotationPermission {
if in == nil {
return nil
}
out := new(AnnotationPermission)
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *Dashboard) DeepCopyInto(out *Dashboard) {
*out = *in
out.TypeMeta = in.TypeMeta
in.ObjectMeta.DeepCopyInto(&out.ObjectMeta)
in.Spec.DeepCopyInto(&out.Spec)
return
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Dashboard.
func (in *Dashboard) DeepCopy() *Dashboard {
if in == nil {
return nil
}
out := new(Dashboard)
in.DeepCopyInto(out)
return out
}
// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
func (in *Dashboard) DeepCopyObject() runtime.Object {
if c := in.DeepCopy(); c != nil {
return c
}
return nil
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *DashboardAccessInfo) DeepCopyInto(out *DashboardAccessInfo) {
*out = *in
out.TypeMeta = in.TypeMeta
if in.AnnotationsPermissions != nil {
in, out := &in.AnnotationsPermissions, &out.AnnotationsPermissions
*out = new(AnnotationPermission)
**out = **in
}
return
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DashboardAccessInfo.
func (in *DashboardAccessInfo) DeepCopy() *DashboardAccessInfo {
if in == nil {
return nil
}
out := new(DashboardAccessInfo)
in.DeepCopyInto(out)
return out
}
// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
func (in *DashboardAccessInfo) DeepCopyObject() runtime.Object {
if c := in.DeepCopy(); c != nil {
return c
}
return nil
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *DashboardList) DeepCopyInto(out *DashboardList) {
*out = *in
out.TypeMeta = in.TypeMeta
in.ListMeta.DeepCopyInto(&out.ListMeta)
if in.Items != nil {
in, out := &in.Items, &out.Items
*out = make([]Dashboard, len(*in))
for i := range *in {
(*in)[i].DeepCopyInto(&(*out)[i])
}
}
return
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DashboardList.
func (in *DashboardList) DeepCopy() *DashboardList {
if in == nil {
return nil
}
out := new(DashboardList)
in.DeepCopyInto(out)
return out
}
// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
func (in *DashboardList) DeepCopyObject() runtime.Object {
if c := in.DeepCopy(); c != nil {
return c
}
return nil
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *DashboardSummary) DeepCopyInto(out *DashboardSummary) {
*out = *in
out.TypeMeta = in.TypeMeta
in.ObjectMeta.DeepCopyInto(&out.ObjectMeta)
in.Spec.DeepCopyInto(&out.Spec)
return
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DashboardSummary.
func (in *DashboardSummary) DeepCopy() *DashboardSummary {
if in == nil {
return nil
}
out := new(DashboardSummary)
in.DeepCopyInto(out)
return out
}
// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
func (in *DashboardSummary) DeepCopyObject() runtime.Object {
if c := in.DeepCopy(); c != nil {
return c
}
return nil
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *DashboardSummaryList) DeepCopyInto(out *DashboardSummaryList) {
*out = *in
out.TypeMeta = in.TypeMeta
in.ListMeta.DeepCopyInto(&out.ListMeta)
if in.Items != nil {
in, out := &in.Items, &out.Items
*out = make([]DashboardSummary, len(*in))
for i := range *in {
(*in)[i].DeepCopyInto(&(*out)[i])
}
}
return
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DashboardSummaryList.
func (in *DashboardSummaryList) DeepCopy() *DashboardSummaryList {
if in == nil {
return nil
}
out := new(DashboardSummaryList)
in.DeepCopyInto(out)
return out
}
// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
func (in *DashboardSummaryList) DeepCopyObject() runtime.Object {
if c := in.DeepCopy(); c != nil {
return c
}
return nil
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *DashboardSummarySpec) DeepCopyInto(out *DashboardSummarySpec) {
*out = *in
if in.Tags != nil {
in, out := &in.Tags, &out.Tags
*out = make([]string, len(*in))
copy(*out, *in)
}
return
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DashboardSummarySpec.
func (in *DashboardSummarySpec) DeepCopy() *DashboardSummarySpec {
if in == nil {
return nil
}
out := new(DashboardSummarySpec)
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *DashboardVersionInfo) DeepCopyInto(out *DashboardVersionInfo) {
*out = *in
return
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DashboardVersionInfo.
func (in *DashboardVersionInfo) DeepCopy() *DashboardVersionInfo {
if in == nil {
return nil
}
out := new(DashboardVersionInfo)
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *DashboardVersionsInfo) DeepCopyInto(out *DashboardVersionsInfo) {
*out = *in
out.TypeMeta = in.TypeMeta
in.ListMeta.DeepCopyInto(&out.ListMeta)
if in.Items != nil {
in, out := &in.Items, &out.Items
*out = make([]DashboardVersionInfo, len(*in))
copy(*out, *in)
}
return
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DashboardVersionsInfo.
func (in *DashboardVersionsInfo) DeepCopy() *DashboardVersionsInfo {
if in == nil {
return nil
}
out := new(DashboardVersionsInfo)
in.DeepCopyInto(out)
return out
}
// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
func (in *DashboardVersionsInfo) DeepCopyObject() runtime.Object {
if c := in.DeepCopy(); c != nil {
return c
}
return nil
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *VersionsQueryOptions) DeepCopyInto(out *VersionsQueryOptions) {
*out = *in
out.TypeMeta = in.TypeMeta
return
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new VersionsQueryOptions.
func (in *VersionsQueryOptions) DeepCopy() *VersionsQueryOptions {
if in == nil {
return nil
}
out := new(VersionsQueryOptions)
in.DeepCopyInto(out)
return out
}
// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
func (in *VersionsQueryOptions) DeepCopyObject() runtime.Object {
if c := in.DeepCopy(); c != nil {
return c
}
return nil
}

View File

@ -0,0 +1,19 @@
//go:build !ignore_autogenerated
// +build !ignore_autogenerated
// SPDX-License-Identifier: AGPL-3.0-only
// Code generated by defaulter-gen. DO NOT EDIT.
package v0alpha1
import (
runtime "k8s.io/apimachinery/pkg/runtime"
)
// RegisterDefaults adds defaulters functions to the given scheme.
// Public to allow building arbitrary schemes.
// All generated defaulters are covering - they call all nested defaulters.
func RegisterDefaults(scheme *runtime.Scheme) error {
return nil
}

File diff suppressed because it is too large Load Diff

View File

@ -61,9 +61,15 @@ func User(ctx context.Context) (*user.SignedInUser, error) {
IsGrafanaAdmin: true,
Permissions: map[int64]map[string][]string{
orgId: {
"*": {"*"},
dashboards.ActionFoldersCreate: {"*"}, // all resources, all scopes
dashboards.ActionFoldersRead: {dashboards.ScopeFoldersAll}, // access to read all folders
"*": {"*"}, // all resources, all scopes
// Dashboards do not support wildcard action
dashboards.ActionDashboardsRead: {"*"},
dashboards.ActionDashboardsCreate: {"*"},
dashboards.ActionDashboardsWrite: {"*"},
dashboards.ActionDashboardsDelete: {"*"},
dashboards.ActionFoldersCreate: {"*"},
dashboards.ActionFoldersRead: {dashboards.ScopeFoldersAll}, // access to read all folders
},
},
}, nil

View File

@ -4,6 +4,7 @@ import (
"context"
"github.com/grafana/grafana/pkg/registry"
"github.com/grafana/grafana/pkg/registry/apis/dashboard"
"github.com/grafana/grafana/pkg/registry/apis/datasource"
"github.com/grafana/grafana/pkg/registry/apis/example"
"github.com/grafana/grafana/pkg/registry/apis/folders"
@ -19,6 +20,7 @@ type Service struct{}
// ProvideRegistryServiceSink is an entry point for each service that will force initialization
// and give each builder the chance to register itself with the main server
func ProvideRegistryServiceSink(
_ *dashboard.DashboardsAPIBuilder,
_ *playlist.PlaylistAPIBuilder,
_ *example.TestingAPIBuilder,
_ *datasource.DataSourceAPIBuilder,

View File

@ -0,0 +1,422 @@
package access
import (
"context"
"database/sql"
"fmt"
"path/filepath"
"time"
v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
dashboardsV0 "github.com/grafana/grafana/pkg/apis/dashboard/v0alpha1"
"github.com/grafana/grafana/pkg/components/simplejson"
"github.com/grafana/grafana/pkg/infra/appcontext"
"github.com/grafana/grafana/pkg/infra/db"
"github.com/grafana/grafana/pkg/kinds"
"github.com/grafana/grafana/pkg/services/accesscontrol"
"github.com/grafana/grafana/pkg/services/dashboards"
"github.com/grafana/grafana/pkg/services/grafana-apiserver/endpoints/request"
"github.com/grafana/grafana/pkg/services/grafana-apiserver/utils"
"github.com/grafana/grafana/pkg/services/provisioning"
"github.com/grafana/grafana/pkg/services/sqlstore/session"
)
var (
_ DashboardAccess = (*dashboardSqlAccess)(nil)
)
type dashboardRow struct {
// Dashboard resource
Dash *dashboardsV0.Dashboard
// Title -- this may come from saved metadata rather than the body
Title string
// The folder UID (needed for access control checks)
FolderUID string
// Needed for fast summary access
Tags []string
// Size (in bytes) of the dashboard payload
Bytes int
// The token we can use that will start a new connection that includes
// this same dashboard
token *continueToken
}
type dashboardSqlAccess struct {
sql db.DB
sess *session.SessionDB
namespacer request.NamespaceMapper
dashStore dashboards.Store
provisioning provisioning.ProvisioningService
}
func NewDashboardAccess(sql db.DB, namespacer request.NamespaceMapper, dashStore dashboards.Store, provisioning provisioning.ProvisioningService) DashboardAccess {
return &dashboardSqlAccess{
sql: sql,
sess: sql.GetSqlxSession(),
namespacer: namespacer,
dashStore: dashStore,
provisioning: provisioning,
}
}
const selector = `SELECT
dashboard.org_id, dashboard.id,
dashboard.uid,slug,
dashboard.folder_uid,
dashboard.created,dashboard.created_by,CreatedUSER.login,
dashboard.updated,dashboard.updated_by,UpdatedUSER.login,
plugin_id,
dashboard_provisioning.name as origin_name,
dashboard_provisioning.external_id as origin_path,
dashboard_provisioning.check_sum as origin_key,
dashboard_provisioning.updated as origin_ts,
dashboard.version,
title,
dashboard.data
FROM dashboard
LEFT OUTER JOIN dashboard_provisioning ON dashboard.id = dashboard_provisioning.dashboard_id
LEFT OUTER JOIN user AS CreatedUSER ON dashboard.created_by = CreatedUSER.id
LEFT OUTER JOIN user AS UpdatedUSER ON dashboard.created_by = UpdatedUSER.id
WHERE is_folder = false`
// GetDashboards implements DashboardAccess.
func (a *dashboardSqlAccess) GetDashboards(ctx context.Context, query *DashboardQuery) (*dashboardsV0.DashboardList, error) {
token, err := readContinueToken(query)
if err != nil {
return nil, err
}
limit := query.Limit
if limit < 1 {
limit = 15 //
}
rows, err := a.doQuery(ctx, selector+`
AND dashboard.org_id=$1
AND dashboard.id>=$2
ORDER BY dashboard.id asc
LIMIT $3
`, query.OrgID, token.id, (limit + 2))
if err != nil {
return nil, err
}
defer func() { _ = rows.Close() }()
totalSize := 0
list := &dashboardsV0.DashboardList{}
if err != nil {
return nil, err
}
for {
row, err := rows.Next()
if err != nil || row == nil {
return list, err
}
totalSize += row.Bytes
if len(list.Items) > 0 && (totalSize > query.MaxBytes || len(list.Items) >= limit) {
row.token.folder = query.FolderUID
list.Continue = row.token.String() // will skip this one but start here next time
return list, err
}
list.Items = append(list.Items, *row.Dash)
}
}
func (a *dashboardSqlAccess) GetDashboard(ctx context.Context, orgId int64, uid string) (*dashboardsV0.Dashboard, error) {
r, err := a.GetDashboards(ctx, &DashboardQuery{
OrgID: orgId,
UID: uid,
})
if err != nil {
return nil, err
}
if len(r.Items) > 0 {
return &r.Items[0], nil
}
return nil, fmt.Errorf("not found")
}
// GetDashboards implements DashboardAccess.
func (a *dashboardSqlAccess) GetDashboardSummaries(ctx context.Context, query *DashboardQuery) (*dashboardsV0.DashboardSummaryList, error) {
token, err := readContinueToken(query)
if err != nil {
return nil, err
}
limit := query.Limit
if limit < 1 {
limit = 15 //
}
rows, err := a.doQuery(ctx, selector+`
AND dashboard.org_id=$1
AND dashboard.id>=$2
ORDER BY dashboard.id asc
LIMIT $3
`, query.OrgID, token.id, (limit + 2))
if err != nil {
return nil, err
}
defer func() { _ = rows.Close() }()
totalSize := 0
list := &dashboardsV0.DashboardSummaryList{}
if err != nil {
return nil, err
}
for {
row, err := rows.Next()
if err != nil || row == nil {
return list, err
}
totalSize += row.Bytes
if len(list.Items) > 0 && (totalSize > query.MaxBytes || len(list.Items) >= limit) {
row.token.folder = query.FolderUID
list.Continue = row.token.String() // will skip this one but start here next time
return list, err
}
list.Items = append(list.Items, toSummary(row))
}
}
func (a *dashboardSqlAccess) GetDashboardSummary(ctx context.Context, orgId int64, uid string) (*dashboardsV0.DashboardSummary, error) {
r, err := a.GetDashboardSummaries(ctx, &DashboardQuery{
OrgID: orgId,
UID: uid,
})
if err != nil {
return nil, err
}
if len(r.Items) > 0 {
return &r.Items[0], nil
}
return nil, fmt.Errorf("not found")
}
func toSummary(row *dashboardRow) dashboardsV0.DashboardSummary {
return dashboardsV0.DashboardSummary{
ObjectMeta: row.Dash.ObjectMeta,
Spec: dashboardsV0.DashboardSummarySpec{
Title: row.Title,
Tags: row.Tags,
},
}
}
func (a *dashboardSqlAccess) doQuery(ctx context.Context, query string, args ...any) (*rowsWrapper, error) {
user, err := appcontext.User(ctx)
if err != nil {
return nil, err
}
rows, err := a.sess.Query(ctx, query, args...)
return &rowsWrapper{
rows: rows,
a: a,
// This looks up rules from the permissions on a user
canReadDashboard: accesscontrol.Checker(user, dashboards.ActionDashboardsRead),
}, err
}
type rowsWrapper struct {
a *dashboardSqlAccess
rows *sql.Rows
idx int
total int64
canReadDashboard func(scopes ...string) bool
}
func (r *rowsWrapper) Close() error {
return r.rows.Close()
}
func (r *rowsWrapper) Next() (*dashboardRow, error) {
// breaks after first readable value
for r.rows.Next() {
r.idx++
d, err := r.a.scanRow(r.rows)
if d != nil {
// Access control checker
scopes := []string{dashboards.ScopeDashboardsProvider.GetResourceScopeUID(d.Dash.Name)}
if d.FolderUID != "" { // Copied from searchV2... not sure the logic is right
scopes = append(scopes, dashboards.ScopeFoldersProvider.GetResourceScopeUID(d.FolderUID))
}
if !r.canReadDashboard(scopes...) {
continue
}
d.token.size = r.total // size before next!
r.total += int64(d.Bytes)
}
// returns the first folder it can
return d, err
}
return nil, nil
}
func (a *dashboardSqlAccess) scanRow(rows *sql.Rows) (*dashboardRow, error) {
dash := &dashboardsV0.Dashboard{
TypeMeta: dashboardsV0.DashboardResourceInfo.TypeMeta(),
ObjectMeta: v1.ObjectMeta{Annotations: make(map[string]string)},
}
row := &dashboardRow{Dash: dash}
var dashboard_id int64
var orgId int64
var slug string
var folder_uid sql.NullString
var updated time.Time
var updatedByID int64
var updatedByName sql.NullString
var created time.Time
var createdByID int64
var createdByName sql.NullString
var plugin_id string
var origin_name sql.NullString
var origin_path sql.NullString
var origin_ts sql.NullInt64
var origin_key sql.NullString
var data []byte // the dashboard JSON
var version int64
err := rows.Scan(&orgId, &dashboard_id, &dash.Name,
&slug, &folder_uid,
&created, &createdByID, &createdByName,
&updated, &updatedByID, &updatedByName,
&plugin_id,
&origin_name, &origin_path, &origin_key, &origin_ts,
&version,
&row.Title, &data,
)
row.token = &continueToken{orgId: orgId, id: dashboard_id}
if err == nil {
dash.ResourceVersion = fmt.Sprintf("%d", created.UnixMilli())
dash.Namespace = a.namespacer(orgId)
dash.UID = utils.CalculateClusterWideUID(dash)
dash.SetCreationTimestamp(v1.NewTime(created))
meta := kinds.MetaAccessor(dash)
meta.SetUpdatedTimestamp(&updated)
meta.SetSlug(slug)
if createdByID > 0 {
meta.SetCreatedBy(fmt.Sprintf("user:%d/%s", createdByID, createdByName.String))
}
if updatedByID > 0 {
meta.SetUpdatedBy(fmt.Sprintf("user:%d/%s", updatedByID, updatedByName.String))
}
if folder_uid.Valid {
meta.SetFolder(folder_uid.String)
row.FolderUID = folder_uid.String
}
if origin_name.Valid {
ts := time.Unix(origin_ts.Int64, 0)
originPath, err := filepath.Rel(
a.provisioning.GetDashboardProvisionerResolvedPath(origin_name.String),
origin_path.String,
)
if err != nil {
return nil, err
}
meta.SetOriginInfo(&kinds.ResourceOriginInfo{
Name: origin_name.String,
Path: originPath,
Key: origin_key.String,
Timestamp: &ts,
})
} else if plugin_id != "" {
meta.SetOriginInfo(&kinds.ResourceOriginInfo{
Name: "plugin",
Path: plugin_id,
})
}
row.Bytes = len(data)
if row.Bytes > 0 {
err = dash.Spec.UnmarshalJSON(data)
if err != nil {
return row, err
}
dash.Spec.Set("id", dashboard_id) // add it so we can get it from the body later
row.Title = dash.Spec.GetNestedString("title")
row.Tags = dash.Spec.GetNestedStringSlice("tags")
}
}
return row, err
}
// DeleteDashboard implements DashboardAccess.
func (a *dashboardSqlAccess) DeleteDashboard(ctx context.Context, orgId int64, uid string) (*dashboardsV0.Dashboard, bool, error) {
dash, err := a.GetDashboard(ctx, orgId, uid)
if err != nil {
return nil, false, err
}
id := dash.Spec.GetNestedInt64("id")
if id == 0 {
return nil, false, fmt.Errorf("could not find id in saved body")
}
err = a.dashStore.DeleteDashboard(ctx, &dashboards.DeleteDashboardCommand{
OrgID: orgId,
ID: id,
})
if err != nil {
return nil, false, err
}
return dash, true, nil
}
// SaveDashboard implements DashboardAccess.
func (a *dashboardSqlAccess) SaveDashboard(ctx context.Context, orgId int64, dash *dashboardsV0.Dashboard) (*dashboardsV0.Dashboard, bool, error) {
created := false
user, err := appcontext.User(ctx)
if err != nil {
return nil, created, err
}
if dash.Name != "" {
dash.Spec.Set("uid", dash.Name)
// Get the previous version to set the internal ID
old, _ := a.dashStore.GetDashboard(ctx, &dashboards.GetDashboardQuery{
OrgID: orgId,
UID: dash.Name,
})
if old != nil {
dash.Spec.Set("id", old.ID)
} else {
dash.Spec.Remove("id") // existing of "id" makes it an update
created = true
}
} else {
dash.Spec.Remove("id")
dash.Spec.Remove("uid")
}
meta := kinds.MetaAccessor(dash)
out, err := a.dashStore.SaveDashboard(ctx, dashboards.SaveDashboardCommand{
OrgID: orgId,
Dashboard: simplejson.NewFromAny(dash.Spec.UnstructuredContent()),
FolderUID: meta.GetFolder(),
Overwrite: true, // already passed the revisionVersion checks!
UserID: user.UserID,
})
if err != nil {
return nil, false, err
}
if out != nil {
created = (out.Created.Unix() == out.Updated.Unix()) // and now?
}
dash, err = a.GetDashboard(ctx, orgId, out.UID)
return dash, created, err
}

View File

@ -0,0 +1,63 @@
package access
import (
"fmt"
"strconv"
"strings"
"github.com/grafana/grafana/pkg/util"
)
type continueToken struct {
orgId int64
id int64 // the internal id (sort by!)
folder string // from the query
size int64
}
func readContinueToken(q *DashboardQuery) (continueToken, error) {
var err error
token := continueToken{}
if q.ContinueToken == "" {
return token, nil
}
parts := strings.Split(q.ContinueToken, "/")
if len(parts) < 3 {
return token, fmt.Errorf("invalid continue token (too few parts)")
}
sub := strings.Split(parts[0], ":")
if sub[0] != "org" {
return token, fmt.Errorf("expected org in first slug")
}
token.orgId, err = strconv.ParseInt(sub[1], 10, 64)
if err != nil {
return token, fmt.Errorf("error parsing orgid")
}
sub = strings.Split(parts[1], ":")
if sub[0] != "start" {
return token, fmt.Errorf("expected internal ID in second slug")
}
token.id, err = strconv.ParseInt(sub[1], 10, 64)
if err != nil {
return token, fmt.Errorf("error parsing updated")
}
sub = strings.Split(parts[2], ":")
if sub[0] != "folder" {
return token, fmt.Errorf("expected folder UID in third slug")
}
token.folder = sub[1]
// Check if the folder filter is the same from the previous query
if token.folder != q.FolderUID {
return token, fmt.Errorf("invalid token, the folder must match previous query")
}
return token, err
}
func (r *continueToken) String() string {
return fmt.Sprintf("org:%d/start:%d/folder:%s/%s",
r.orgId, r.id, r.folder, util.ByteCountSI(r.size))
}

View File

@ -0,0 +1,31 @@
package access
import (
"context"
dashboardsV0 "github.com/grafana/grafana/pkg/apis/dashboard/v0alpha1"
)
// This does not check if you have permissions!
type DashboardQuery struct {
OrgID int64
UID string // to select a single dashboard
FolderUID string
Limit int
MaxBytes int
// The token from previous query
ContinueToken string
}
type DashboardAccess interface {
GetDashboard(ctx context.Context, orgId int64, uid string) (*dashboardsV0.Dashboard, error)
GetDashboards(ctx context.Context, query *DashboardQuery) (*dashboardsV0.DashboardList, error)
GetDashboardSummary(ctx context.Context, orgId int64, uid string) (*dashboardsV0.DashboardSummary, error)
GetDashboardSummaries(ctx context.Context, query *DashboardQuery) (*dashboardsV0.DashboardSummaryList, error)
SaveDashboard(ctx context.Context, orgId int64, dash *dashboardsV0.Dashboard) (*dashboardsV0.Dashboard, bool, error)
DeleteDashboard(ctx context.Context, orgId int64, uid string) (*dashboardsV0.Dashboard, bool, error)
}

View File

@ -0,0 +1,95 @@
package dashboard
import (
"context"
"k8s.io/apiserver/pkg/authorization/authorizer"
"github.com/grafana/grafana/pkg/apis/dashboard/v0alpha1"
"github.com/grafana/grafana/pkg/infra/appcontext"
"github.com/grafana/grafana/pkg/services/dashboards"
"github.com/grafana/grafana/pkg/services/grafana-apiserver/endpoints/request"
"github.com/grafana/grafana/pkg/services/guardian"
)
func (b *DashboardsAPIBuilder) GetAuthorizer() authorizer.Authorizer {
return authorizer.AuthorizerFunc(
func(ctx context.Context, attr authorizer.Attributes) (authorized authorizer.Decision, reason string, err error) {
if !attr.IsResourceRequest() {
return authorizer.DecisionNoOpinion, "", nil
}
user, err := appcontext.User(ctx)
if err != nil {
return authorizer.DecisionDeny, "", err
}
if attr.GetName() == "" {
// Discourage use of the "list" command for non super admin users
if attr.GetVerb() == "list" && attr.GetResource() == v0alpha1.DashboardResourceInfo.GroupResource().Resource {
if !user.IsGrafanaAdmin {
return authorizer.DecisionDeny, "list summary objects (or connect as GrafanaAdmin)", err
}
}
return authorizer.DecisionNoOpinion, "", nil
}
ns := attr.GetNamespace()
if ns == "" {
return authorizer.DecisionDeny, "expected namespace", nil
}
info, err := request.ParseNamespace(attr.GetNamespace())
if err != nil {
return authorizer.DecisionDeny, "error reading org from namespace", err
}
// expensive path to lookup permissions for a single dashboard
dto, err := b.dashboardService.GetDashboard(ctx, &dashboards.GetDashboardQuery{
UID: attr.GetName(),
OrgID: info.OrgID,
})
if err != nil {
return authorizer.DecisionDeny, "error loading dashboard", err
}
ok := false
guardian, err := guardian.NewByDashboard(ctx, dto, info.OrgID, user)
if err != nil {
return authorizer.DecisionDeny, "", err
}
switch attr.GetVerb() {
case "get":
ok, err = guardian.CanView()
if !ok || err != nil {
return authorizer.DecisionDeny, "can not view dashboard", err
}
case "create":
fallthrough
case "post":
ok, err = guardian.CanSave() // vs Edit?
if !ok || err != nil {
return authorizer.DecisionDeny, "can not save dashboard", err
}
case "update":
fallthrough
case "patch":
fallthrough
case "put":
ok, err = guardian.CanEdit() // vs Save
if !ok || err != nil {
return authorizer.DecisionDeny, "can not edit dashboard", err
}
case "delete":
ok, err = guardian.CanDelete()
if !ok || err != nil {
return authorizer.DecisionDeny, "can not delete dashboard", err
}
default:
b.log.Info("unknown verb", "verb", attr.GetVerb())
return authorizer.DecisionNoOpinion, "unsupported verb", nil // Unknown verb
}
return authorizer.DecisionAllow, "", nil
})
}

View File

@ -0,0 +1,155 @@
package dashboard
import (
"context"
"fmt"
"strings"
"time"
"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/apis"
"github.com/grafana/grafana/pkg/apis/dashboard/v0alpha1"
"github.com/grafana/grafana/pkg/registry/apis/dashboard/access"
"github.com/grafana/grafana/pkg/services/grafana-apiserver/endpoints/request"
)
var (
_ rest.Storage = (*dashboardStorage)(nil)
_ rest.Scoper = (*dashboardStorage)(nil)
_ rest.SingularNameProvider = (*dashboardStorage)(nil)
_ rest.Getter = (*dashboardStorage)(nil)
_ rest.Lister = (*dashboardStorage)(nil)
_ rest.Creater = (*dashboardStorage)(nil)
_ rest.Updater = (*dashboardStorage)(nil)
_ rest.GracefulDeleter = (*dashboardStorage)(nil)
)
type dashboardStorage struct {
resource apis.ResourceInfo
access access.DashboardAccess
tableConverter rest.TableConvertor
}
func (s *dashboardStorage) New() runtime.Object {
return s.resource.NewFunc()
}
func (s *dashboardStorage) Destroy() {}
func (s *dashboardStorage) NamespaceScoped() bool {
return true
}
func (s *dashboardStorage) GetSingularName() string {
return s.resource.GetSingularName()
}
func (s *dashboardStorage) NewList() runtime.Object {
return s.resource.NewListFunc()
}
func (s *dashboardStorage) ConvertToTable(ctx context.Context, object runtime.Object, tableOptions runtime.Object) (*metav1.Table, error) {
return s.tableConverter.ConvertToTable(ctx, object, tableOptions)
}
func (s *dashboardStorage) Create(ctx context.Context,
obj runtime.Object,
createValidation rest.ValidateObjectFunc,
options *metav1.CreateOptions,
) (runtime.Object, error) {
info, err := request.NamespaceInfoFrom(ctx, true)
if err != nil {
return nil, err
}
p, ok := obj.(*v0alpha1.Dashboard)
if !ok {
return nil, fmt.Errorf("expected dashboard?")
}
// HACK to simplify unique name testing from kubectl
t := p.Spec.GetNestedString("title")
if strings.Contains(t, "${NOW}") {
t = strings.ReplaceAll(t, "${NOW}", fmt.Sprintf("%d", time.Now().Unix()))
p.Spec.Set("title", t)
}
dash, _, err := s.access.SaveDashboard(ctx, info.OrgID, p)
return dash, err
}
func (s *dashboardStorage) Update(ctx context.Context,
name string,
objInfo rest.UpdatedObjectInfo,
createValidation rest.ValidateObjectFunc,
updateValidation rest.ValidateObjectUpdateFunc,
forceAllowCreate bool,
options *metav1.UpdateOptions,
) (runtime.Object, bool, error) {
info, err := request.NamespaceInfoFrom(ctx, true)
if err != nil {
return nil, false, err
}
created := false
old, err := s.Get(ctx, name, nil)
if err != nil {
return old, created, err
}
obj, err := objInfo.UpdatedObject(ctx, old)
if err != nil {
return old, created, err
}
p, ok := obj.(*v0alpha1.Dashboard)
if !ok {
return nil, created, fmt.Errorf("expected dashboard after update")
}
_, created, err = s.access.SaveDashboard(ctx, info.OrgID, p)
if err == nil {
r, err := s.Get(ctx, name, nil)
return r, created, err
}
return nil, created, err
}
// GracefulDeleter
func (s *dashboardStorage) Delete(ctx context.Context, name string, deleteValidation rest.ValidateObjectFunc, options *metav1.DeleteOptions) (runtime.Object, bool, error) {
info, err := request.NamespaceInfoFrom(ctx, true)
if err != nil {
return nil, false, err
}
return s.access.DeleteDashboard(ctx, info.OrgID, name)
}
func (s *dashboardStorage) List(ctx context.Context, options *internalversion.ListOptions) (runtime.Object, error) {
orgId, err := request.OrgIDForList(ctx)
if err != nil {
return nil, err
}
// fmt.Printf("LIST: %s\n", options.Continue)
query := &access.DashboardQuery{
OrgID: orgId,
Limit: int(options.Limit),
MaxBytes: 2 * 1024 * 1024, // 2MB,
ContinueToken: options.Continue,
}
return s.access.GetDashboards(ctx, query)
}
func (s *dashboardStorage) Get(ctx context.Context, name string, options *metav1.GetOptions) (runtime.Object, error) {
info, err := request.NamespaceInfoFrom(ctx, true)
if err != nil {
return nil, err
}
return s.access.GetDashboard(ctx, info.OrgID, name)
}

View File

@ -0,0 +1,200 @@
package dashboard
import (
"fmt"
"time"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/apimachinery/pkg/runtime/serializer"
"k8s.io/apiserver/pkg/registry/generic"
genericregistry "k8s.io/apiserver/pkg/registry/generic/registry"
"k8s.io/apiserver/pkg/registry/rest"
genericapiserver "k8s.io/apiserver/pkg/server"
common "k8s.io/kube-openapi/pkg/common"
"github.com/grafana/grafana/pkg/apis/dashboard/v0alpha1"
"github.com/grafana/grafana/pkg/infra/db"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/registry/apis/dashboard/access"
"github.com/grafana/grafana/pkg/services/accesscontrol"
"github.com/grafana/grafana/pkg/services/dashboards"
dashver "github.com/grafana/grafana/pkg/services/dashboardversion"
"github.com/grafana/grafana/pkg/services/featuremgmt"
grafanaapiserver "github.com/grafana/grafana/pkg/services/grafana-apiserver"
"github.com/grafana/grafana/pkg/services/grafana-apiserver/endpoints/request"
grafanaregistry "github.com/grafana/grafana/pkg/services/grafana-apiserver/registry/generic"
grafanarest "github.com/grafana/grafana/pkg/services/grafana-apiserver/rest"
"github.com/grafana/grafana/pkg/services/grafana-apiserver/utils"
"github.com/grafana/grafana/pkg/services/provisioning"
"github.com/grafana/grafana/pkg/setting"
)
var _ grafanaapiserver.APIGroupBuilder = (*DashboardsAPIBuilder)(nil)
// This is used just so wire has something unique to return
type DashboardsAPIBuilder struct {
dashboardService dashboards.DashboardService
dashboardVersionService dashver.Service
accessControl accesscontrol.AccessControl
namespacer request.NamespaceMapper
access access.DashboardAccess
dashStore dashboards.Store
log log.Logger
}
func RegisterAPIService(cfg *setting.Cfg, features featuremgmt.FeatureToggles,
apiregistration grafanaapiserver.APIRegistrar,
dashboardService dashboards.DashboardService,
dashboardVersionService dashver.Service,
accessControl accesscontrol.AccessControl,
provisioning provisioning.ProvisioningService,
dashStore dashboards.Store,
sql db.DB,
) *DashboardsAPIBuilder {
if !features.IsEnabledGlobally(featuremgmt.FlagGrafanaAPIServerWithExperimentalAPIs) {
return nil // skip registration unless opting into experimental apis
}
namespacer := request.GetNamespaceMapper(cfg)
builder := &DashboardsAPIBuilder{
dashboardService: dashboardService,
dashboardVersionService: dashboardVersionService,
dashStore: dashStore,
accessControl: accessControl,
namespacer: namespacer,
access: access.NewDashboardAccess(sql, namespacer, dashStore, provisioning),
log: log.New("grafana-apiserver.dashboards"),
}
apiregistration.RegisterAPI(builder)
return builder
}
func (b *DashboardsAPIBuilder) GetGroupVersion() schema.GroupVersion {
return v0alpha1.DashboardResourceInfo.GroupVersion()
}
func addKnownTypes(scheme *runtime.Scheme, gv schema.GroupVersion) {
scheme.AddKnownTypes(gv,
&v0alpha1.Dashboard{},
&v0alpha1.DashboardList{},
&v0alpha1.DashboardAccessInfo{},
&v0alpha1.DashboardVersionsInfo{},
&v0alpha1.DashboardSummary{},
&v0alpha1.DashboardSummaryList{},
&v0alpha1.VersionsQueryOptions{},
)
}
func (b *DashboardsAPIBuilder) InstallSchema(scheme *runtime.Scheme) error {
resourceInfo := v0alpha1.DashboardResourceInfo
addKnownTypes(scheme, resourceInfo.GroupVersion())
// Link this version to the internal representation.
// This is used for server-side-apply (PATCH), and avoids the error:
// "no kind is registered for the type"
addKnownTypes(scheme, schema.GroupVersion{
Group: resourceInfo.GroupVersion().Group,
Version: runtime.APIVersionInternal,
})
// If multiple versions exist, then register conversions from zz_generated.conversion.go
// if err := playlist.RegisterConversions(scheme); err != nil {
// return err
// }
metav1.AddToGroupVersion(scheme, resourceInfo.GroupVersion())
return scheme.SetVersionPriority(resourceInfo.GroupVersion())
}
func (b *DashboardsAPIBuilder) GetAPIGroupInfo(
scheme *runtime.Scheme,
codecs serializer.CodecFactory, // pointer?
optsGetter generic.RESTOptionsGetter,
) (*genericapiserver.APIGroupInfo, error) {
apiGroupInfo := genericapiserver.NewDefaultAPIGroupInfo(v0alpha1.GROUP, scheme, metav1.ParameterCodec, codecs)
resourceInfo := v0alpha1.DashboardResourceInfo
strategy := grafanaregistry.NewStrategy(scheme)
store := &genericregistry.Store{
NewFunc: resourceInfo.NewFunc,
NewListFunc: resourceInfo.NewListFunc,
PredicateFunc: grafanaregistry.Matcher,
DefaultQualifiedResource: resourceInfo.GroupResource(),
SingularQualifiedResource: resourceInfo.SingularGroupResource(),
CreateStrategy: strategy,
UpdateStrategy: strategy,
DeleteStrategy: strategy,
}
store.TableConvertor = utils.NewTableConverter(
store.DefaultQualifiedResource,
[]metav1.TableColumnDefinition{
{Name: "Name", Type: "string", Format: "name"},
{Name: "Title", Type: "string", Format: "string", Description: "The dashboard name"},
{Name: "Created At", Type: "date"},
},
func(obj any) ([]interface{}, error) {
dash, ok := obj.(*v0alpha1.Dashboard)
if ok {
return []interface{}{
dash.Name,
dash.Spec.GetNestedString("title"),
dash.CreationTimestamp.UTC().Format(time.RFC3339),
}, nil
}
summary, ok := obj.(*v0alpha1.DashboardSummary)
if ok {
return []interface{}{
dash.Name,
summary.Spec.Title,
dash.CreationTimestamp.UTC().Format(time.RFC3339),
}, nil
}
return nil, fmt.Errorf("expected dashboard or summary")
})
legacyStore := &dashboardStorage{
resource: resourceInfo,
access: b.access,
tableConverter: store.TableConvertor,
}
storage := map[string]rest.Storage{}
storage[resourceInfo.StoragePath()] = legacyStore
storage[resourceInfo.StoragePath("access")] = &AccessREST{
builder: b,
}
storage[resourceInfo.StoragePath("versions")] = &VersionsREST{
builder: b,
}
// Dual writes if a RESTOptionsGetter is provided
if optsGetter != nil {
options := &generic.StoreOptions{RESTOptions: optsGetter, AttrFunc: grafanaregistry.GetAttrs}
if err := store.CompleteWithOptions(options); err != nil {
return nil, err
}
storage[resourceInfo.StoragePath()] = grafanarest.NewDualWriter(legacyStore, store)
}
// Summary
resourceInfo = v0alpha1.DashboardSummaryResourceInfo
storage[resourceInfo.StoragePath()] = &summaryStorage{
resource: resourceInfo,
access: b.access,
tableConverter: store.TableConvertor,
}
apiGroupInfo.VersionedResourcesStorageMap[v0alpha1.VERSION] = storage
return &apiGroupInfo, nil
}
func (b *DashboardsAPIBuilder) GetOpenAPIDefinitions() common.GetOpenAPIDefinitions {
return v0alpha1.GetOpenAPIDefinitions
}
func (b *DashboardsAPIBuilder) GetAPIRoutes() *grafanaapiserver.APIRoutes {
return nil // no custom API routes
}

View File

@ -0,0 +1,105 @@
package dashboard
import (
"context"
"fmt"
"net/http"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apiserver/pkg/registry/rest"
"github.com/grafana/grafana/pkg/apis/dashboard/v0alpha1"
"github.com/grafana/grafana/pkg/infra/appcontext"
"github.com/grafana/grafana/pkg/services/accesscontrol"
"github.com/grafana/grafana/pkg/services/auth/identity"
dashboardssvc "github.com/grafana/grafana/pkg/services/dashboards"
"github.com/grafana/grafana/pkg/services/grafana-apiserver/endpoints/request"
"github.com/grafana/grafana/pkg/services/guardian"
)
type AccessREST struct {
builder *DashboardsAPIBuilder
}
var _ = rest.Connecter(&AccessREST{})
func (r *AccessREST) New() runtime.Object {
return &v0alpha1.DashboardAccessInfo{}
}
func (r *AccessREST) Destroy() {
}
func (r *AccessREST) ConnectMethods() []string {
return []string{"GET"}
}
func (r *AccessREST) NewConnectOptions() (runtime.Object, bool, string) {
return &v0alpha1.VersionsQueryOptions{}, false, ""
}
func (r *AccessREST) Connect(ctx context.Context, name string, opts runtime.Object, responder rest.Responder) (http.Handler, error) {
info, err := request.NamespaceInfoFrom(ctx, true)
if err != nil {
return nil, err
}
user, err := appcontext.User(ctx)
if err != nil {
return nil, err
}
dto, err := r.builder.dashboardService.GetDashboard(ctx, &dashboardssvc.GetDashboardQuery{
UID: name,
OrgID: info.OrgID,
})
if err != nil {
return nil, err
}
guardian, err := guardian.NewByDashboard(ctx, dto, info.OrgID, user)
if err != nil {
return nil, err
}
canView, err := guardian.CanView()
if err != nil || !canView {
return nil, fmt.Errorf("not allowed to view")
}
access := &v0alpha1.DashboardAccessInfo{}
access.CanEdit, _ = guardian.CanEdit()
access.CanSave, _ = guardian.CanSave()
access.CanAdmin, _ = guardian.CanAdmin()
access.CanDelete, _ = guardian.CanDelete()
access.CanStar = user.IsRealUser() && !user.IsAnonymous
access.AnnotationsPermissions = &v0alpha1.AnnotationPermission{}
r.getAnnotationPermissionsByScope(ctx, user, &access.AnnotationsPermissions.Dashboard, accesscontrol.ScopeAnnotationsTypeDashboard)
r.getAnnotationPermissionsByScope(ctx, user, &access.AnnotationsPermissions.Organization, accesscontrol.ScopeAnnotationsTypeOrganization)
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
responder.Object(http.StatusOK, access)
}), nil
}
func (r *AccessREST) getAnnotationPermissionsByScope(ctx context.Context, user identity.Requester, actions *v0alpha1.AnnotationActions, scope string) {
var err error
evaluate := accesscontrol.EvalPermission(accesscontrol.ActionAnnotationsCreate, scope)
actions.CanAdd, err = r.builder.accessControl.Evaluate(ctx, user, evaluate)
if err != nil {
r.builder.log.Warn("Failed to evaluate permission", "err", err, "action", accesscontrol.ActionAnnotationsCreate, "scope", scope)
}
evaluate = accesscontrol.EvalPermission(accesscontrol.ActionAnnotationsDelete, scope)
actions.CanDelete, err = r.builder.accessControl.Evaluate(ctx, user, evaluate)
if err != nil {
r.builder.log.Warn("Failed to evaluate permission", "err", err, "action", accesscontrol.ActionAnnotationsDelete, "scope", scope)
}
evaluate = accesscontrol.EvalPermission(accesscontrol.ActionAnnotationsWrite, scope)
actions.CanEdit, err = r.builder.accessControl.Evaluate(ctx, user, evaluate)
if err != nil {
r.builder.log.Warn("Failed to evaluate permission", "err", err, "action", accesscontrol.ActionAnnotationsWrite, "scope", scope)
}
}

View File

@ -0,0 +1,107 @@
package dashboard
import (
"context"
"fmt"
"net/http"
"strconv"
"strings"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apiserver/pkg/registry/rest"
"github.com/grafana/grafana/pkg/apis/dashboard/v0alpha1"
dashver "github.com/grafana/grafana/pkg/services/dashboardversion"
"github.com/grafana/grafana/pkg/services/grafana-apiserver/endpoints/request"
)
type VersionsREST struct {
builder *DashboardsAPIBuilder
}
var _ = rest.Connecter(&VersionsREST{})
func (r *VersionsREST) New() runtime.Object {
return &v0alpha1.DashboardVersionsInfo{}
}
func (r *VersionsREST) Destroy() {
}
func (r *VersionsREST) ConnectMethods() []string {
return []string{"GET"}
}
func (r *VersionsREST) NewConnectOptions() (runtime.Object, bool, string) {
return nil, true, ""
}
func (r *VersionsREST) Connect(ctx context.Context, uid string, opts runtime.Object, responder rest.Responder) (http.Handler, error) {
info, err := request.NamespaceInfoFrom(ctx, true)
if err != nil {
return nil, err
}
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
path := req.URL.Path
idx := strings.LastIndex(path, "/versions/")
if idx > 0 {
key := path[strings.LastIndex(path, "/")+1:]
version, err := strconv.Atoi(key)
if err != nil {
responder.Error(err)
return
}
dto, err := r.builder.dashboardVersionService.Get(ctx, &dashver.GetDashboardVersionQuery{
DashboardUID: uid,
OrgID: info.OrgID,
Version: version,
})
if err != nil {
responder.Error(err)
return
}
data, _ := dto.Data.Map()
// Convert the version to a regular dashboard
dash := &v0alpha1.Dashboard{
ObjectMeta: metav1.ObjectMeta{
Name: uid,
CreationTimestamp: metav1.NewTime(dto.Created),
},
Spec: v0alpha1.Unstructured{Object: data},
}
responder.Object(100, dash)
return
}
// Or list versions
rsp, err := r.builder.dashboardVersionService.List(ctx, &dashver.ListDashboardVersionsQuery{
DashboardUID: uid,
OrgID: info.OrgID,
})
if err != nil {
responder.Error(err)
return
}
versions := &v0alpha1.DashboardVersionsInfo{}
for _, v := range rsp {
info := v0alpha1.DashboardVersionInfo{
Version: v.Version,
Created: v.Created.UnixMilli(),
Message: v.Message,
}
if v.ParentVersion != v.Version {
info.ParentVersion = v.ParentVersion
}
if v.CreatedBy > 0 {
info.CreatedBy = fmt.Sprintf("%d", v.CreatedBy)
}
versions.Items = append(versions.Items, info)
}
responder.Object(http.StatusOK, versions)
}), nil
}

View File

@ -0,0 +1,74 @@
package dashboard
import (
"context"
"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/apis"
"github.com/grafana/grafana/pkg/registry/apis/dashboard/access"
"github.com/grafana/grafana/pkg/services/grafana-apiserver/endpoints/request"
)
var (
_ rest.Storage = (*summaryStorage)(nil)
_ rest.Scoper = (*summaryStorage)(nil)
_ rest.SingularNameProvider = (*summaryStorage)(nil)
_ rest.Getter = (*summaryStorage)(nil)
_ rest.Lister = (*summaryStorage)(nil)
)
type summaryStorage struct {
resource apis.ResourceInfo
access access.DashboardAccess
tableConverter rest.TableConvertor
}
func (s *summaryStorage) New() runtime.Object {
return s.resource.NewFunc()
}
func (s *summaryStorage) Destroy() {}
func (s *summaryStorage) NamespaceScoped() bool {
return true
}
func (s *summaryStorage) GetSingularName() string {
return s.resource.GetSingularName()
}
func (s *summaryStorage) NewList() runtime.Object {
return s.resource.NewListFunc()
}
func (s *summaryStorage) ConvertToTable(ctx context.Context, object runtime.Object, tableOptions runtime.Object) (*metav1.Table, error) {
return s.tableConverter.ConvertToTable(ctx, object, tableOptions)
}
func (s *summaryStorage) List(ctx context.Context, options *internalversion.ListOptions) (runtime.Object, error) {
orgId, err := request.OrgIDForList(ctx)
if err != nil {
return nil, err
}
query := &access.DashboardQuery{
OrgID: orgId,
Limit: int(options.Limit),
MaxBytes: 2 * 1024 * 1024, // 2MB,
ContinueToken: options.Continue,
}
return s.access.GetDashboardSummaries(ctx, query)
}
func (s *summaryStorage) Get(ctx context.Context, name string, options *metav1.GetOptions) (runtime.Object, error) {
info, err := request.NamespaceInfoFrom(ctx, true)
if err != nil {
return nil, err
}
return s.access.GetDashboardSummary(ctx, info.OrgID, name)
}

View File

@ -3,6 +3,7 @@ package apiregistry
import (
"github.com/google/wire"
"github.com/grafana/grafana/pkg/registry/apis/dashboard"
"github.com/grafana/grafana/pkg/registry/apis/datasource"
"github.com/grafana/grafana/pkg/registry/apis/example"
"github.com/grafana/grafana/pkg/registry/apis/folders"
@ -14,6 +15,7 @@ var WireSet = wire.NewSet(
// Each must be added here *and* in the ServiceSink above
playlist.RegisterAPIService,
dashboard.RegisterAPIService,
example.RegisterAPIService,
datasource.RegisterAPIService,
folders.RegisterAPIService,

View File

@ -4,12 +4,8 @@ import (
"fmt"
"time"
v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"github.com/grafana/grafana/pkg/components/simplejson"
"github.com/grafana/grafana/pkg/infra/slugify"
"github.com/grafana/grafana/pkg/kinds"
"github.com/grafana/grafana/pkg/kinds/dashboard"
"github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/services/auth/identity"
"github.com/grafana/grafana/pkg/services/dashboards/dashboardaccess"
@ -67,55 +63,6 @@ func (d *Dashboard) SetVersion(version int) {
d.Data.Set("version", version)
}
func (d *Dashboard) ToResource() kinds.GrafanaResource[simplejson.Json, any] {
parent := dashboard.NewK8sResource(d.UID, nil)
res := kinds.GrafanaResource[simplejson.Json, any]{
Kind: parent.Kind,
APIVersion: parent.APIVersion,
Metadata: kinds.GrafanaResourceMetadata{
Name: d.UID,
Annotations: make(map[string]string),
Labels: make(map[string]string),
CreationTimestamp: v1.NewTime(d.Created),
ResourceVersion: fmt.Sprintf("%d", d.Version),
},
}
if d.Data != nil {
copy := &simplejson.Json{}
db, _ := d.Data.ToDB()
_ = copy.FromDB(db)
copy.Del("id")
copy.Del("version") // ???
copy.Del("uid") // duplicated to name
res.Spec = copy
}
d.UpdateSlug()
res.Metadata.SetUpdatedTimestamp(&d.Updated)
res.Metadata.SetSlug(d.Slug)
if d.CreatedBy > 0 {
res.Metadata.SetCreatedBy(fmt.Sprintf("user:%d", d.CreatedBy))
}
if d.UpdatedBy > 0 {
res.Metadata.SetUpdatedBy(fmt.Sprintf("user:%d", d.UpdatedBy))
}
if d.PluginID != "" {
res.Metadata.SetOriginInfo(&kinds.ResourceOriginInfo{
Name: "plugin",
Key: d.PluginID,
})
}
// nolint:staticcheck
if d.FolderID > 0 {
res.Metadata.SetFolder(fmt.Sprintf("folder:%d", d.FolderID))
}
if d.IsFolder {
res.Kind = "Folder"
}
return res
}
// NewDashboard creates a new dashboard
func NewDashboard(title string) *Dashboard {
dash := &Dashboard{}

View File

@ -1,10 +1,7 @@
package dashboards
import (
"encoding/json"
"fmt"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@ -91,54 +88,3 @@ func TestSlugifyTitle(t *testing.T) {
})
}
}
func TestResourceConversion(t *testing.T) {
body := simplejson.New()
body.Set("title", "test dash")
body.Set("tags", []string{"hello", "world"})
dash := NewDashboardFromJson(body)
dash.SetUID("TheUID")
dash.SetVersion(10)
dash.Created = time.UnixMilli(946713600000).UTC() // 2000-01-01
dash.Updated = time.UnixMilli(1262332800000).UTC() // 2010-01-01
dash.CreatedBy = 10
dash.UpdatedBy = 11
dash.PluginID = "plugin-xyz"
// nolint:staticcheck
dash.FolderID = 1234
dash.SetID(12345) // should be removed in resource version
dst := dash.ToResource()
require.Equal(t, int64(12345), dash.ID)
require.Equal(t, int64(12345), dash.Data.Get("id").MustInt64(0))
out, err := json.MarshalIndent(dst, "", " ")
require.NoError(t, err)
fmt.Printf("%s", string(out))
require.JSONEq(t, `{
"apiVersion": "v0-0-alpha",
"kind": "Dashboard",
"metadata": {
"name": "TheUID",
"resourceVersion": "10",
"creationTimestamp": "2000-01-01T08:00:00Z",
"annotations": {
"grafana.app/createdBy": "user:10",
"grafana.app/folder": "folder:1234",
"grafana.app/originKey": "plugin-xyz",
"grafana.app/originName": "plugin",
"grafana.app/slug": "test-dash",
"grafana.app/updatedBy": "user:11",
"grafana.app/updatedTimestamp": "2010-01-01T08:00:00Z"
}
},
"spec": {
"tags": [
"hello",
"world"
],
"title": "test dash"
}
}`, string(out))
}

View File

@ -0,0 +1,114 @@
package dashboards
import (
"testing"
"github.com/stretchr/testify/require"
"github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/tests/apis"
"github.com/grafana/grafana/pkg/tests/testinfra"
)
func TestRequiresDevMode(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test")
}
helper := apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{
AppModeProduction: true, // should fail
DisableAnonymous: true,
EnableFeatureToggles: []string{
featuremgmt.FlagGrafanaAPIServer,
featuremgmt.FlagGrafanaAPIServerWithExperimentalAPIs, // Required to start the example service
},
})
_, err := helper.NewDiscoveryClient().ServerResourcesForGroupVersion("dashboard.grafana.app/v0alpha1")
require.Error(t, err)
}
func TestDashboardsApp(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test")
}
helper := apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{
AppModeProduction: false, // required for experimental APIs
DisableAnonymous: true,
EnableFeatureToggles: []string{
featuremgmt.FlagGrafanaAPIServer,
featuremgmt.FlagGrafanaAPIServerWithExperimentalAPIs, // Required to start the example service
},
})
_, err := helper.NewDiscoveryClient().ServerResourcesForGroupVersion("dashboard.grafana.app/v0alpha1")
require.NoError(t, err)
t.Run("Check discovery client", func(t *testing.T) {
disco := helper.GetGroupVersionInfoJSON("dashboard.grafana.app")
// fmt.Printf("%s", string(disco))
require.JSONEq(t, `[
{
"freshness": "Current",
"resources": [
{
"resource": "dashboards",
"responseKind": {
"group": "",
"kind": "Dashboard",
"version": ""
},
"scope": "Namespaced",
"singularResource": "dashboard",
"subresources": [
{
"responseKind": {
"group": "",
"kind": "DashboardAccessInfo",
"version": ""
},
"subresource": "access",
"verbs": [
"get"
]
},
{
"responseKind": {
"group": "",
"kind": "DashboardVersionsInfo",
"version": ""
},
"subresource": "versions",
"verbs": [
"get"
]
}
],
"verbs": [
"create",
"delete",
"get",
"list",
"patch",
"update"
]
},
{
"resource": "summary",
"responseKind": {
"group": "",
"kind": "DashboardSummary",
"version": ""
},
"scope": "Namespaced",
"singularResource": "summary",
"verbs": [
"get",
"list"
]
}
],
"version": "v0alpha1"
}
]`, disco)
})
}

View File

@ -0,0 +1,6 @@
apiVersion: dashboard.grafana.app/v0alpha1
kind: Dashboard
metadata:
generateName: x # anything is ok here... except yes or true -- they become boolean!
spec:
title: Dashboard with auto generated UID ${NOW}

View File

@ -0,0 +1,6 @@
apiVersion: dashboard.grafana.app/v0alpha1
kind: Dashboard
metadata:
name: test
spec:
title: Test dashboard (apply from k8s; PATCH) X

View File

@ -0,0 +1,6 @@
apiVersion: dashboard.grafana.app/v0alpha1
kind: Dashboard
metadata:
name: test
spec:
title: Test dashboard (created from k8s; POST)

View File

@ -0,0 +1,6 @@
apiVersion: dashboard.grafana.app/v0alpha1
kind: Dashboard
metadata:
name: test
spec:
title: Test dashboard (replaced from k8s; PUT)