Files
grafana/pkg/registry/apis/dashboard/legacy/sql_dashboards.go
2025-02-18 12:30:11 -06:00

593 lines
16 KiB
Go

package legacy
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"path/filepath"
"strconv"
"sync"
"time"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/utils/ptr"
claims "github.com/grafana/authlib/types"
"github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1"
"github.com/grafana/grafana/pkg/apimachinery/identity"
"github.com/grafana/grafana/pkg/apimachinery/utils"
dashboard "github.com/grafana/grafana/pkg/apis/dashboard"
"github.com/grafana/grafana/pkg/components/simplejson"
"github.com/grafana/grafana/pkg/registry/apis/dashboard/legacysearcher"
"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/provisioning"
"github.com/grafana/grafana/pkg/services/search/sort"
"github.com/grafana/grafana/pkg/storage/legacysql"
"github.com/grafana/grafana/pkg/storage/unified/resource"
"github.com/grafana/grafana/pkg/storage/unified/sql/sqltemplate"
)
var (
_ DashboardAccess = (*dashboardSqlAccess)(nil)
)
type dashboardRow struct {
// The numeric version for this dashboard
RV int64
// Dashboard resource
Dash *dashboard.Dashboard
// The folder UID (needed for access control checks)
FolderUID string
// The token we can use that will start a new connection that includes
// this same dashboard
token *continueToken
}
type dashboardSqlAccess struct {
sql legacysql.LegacyDatabaseProvider
namespacer request.NamespaceMapper
provisioning provisioning.ProvisioningService
// Use for writing (not reading)
dashStore dashboards.Store
softDelete bool
dashboardSearchClient legacysearcher.DashboardSearchClient
// Typically one... the server wrapper
subscribers []chan *resource.WrittenEvent
mutex sync.Mutex
}
func NewDashboardAccess(sql legacysql.LegacyDatabaseProvider,
namespacer request.NamespaceMapper,
dashStore dashboards.Store,
provisioning provisioning.ProvisioningService,
softDelete bool,
sorter sort.Service,
) DashboardAccess {
dashboardSearchClient := legacysearcher.NewDashboardSearchClient(dashStore, sorter)
return &dashboardSqlAccess{
sql: sql,
namespacer: namespacer,
dashStore: dashStore,
provisioning: provisioning,
softDelete: softDelete,
dashboardSearchClient: *dashboardSearchClient,
}
}
func (a *dashboardSqlAccess) getRows(ctx context.Context, sql *legacysql.LegacyDatabaseHelper, query *DashboardQuery) (*rowsWrapper, error) {
if len(query.Labels) > 0 {
return nil, fmt.Errorf("labels not yet supported")
// if query.Requirements.Folder != nil {
// args = append(args, *query.Requirements.Folder)
// sqlcmd = fmt.Sprintf("%s AND dashboard.folder_uid=?$%d", sqlcmd, len(args))
// }
}
req := newQueryReq(sql, query)
tmpl := sqlQueryDashboards
if query.UseHistoryTable() && query.GetTrash {
return nil, fmt.Errorf("trash not included in history table")
}
rawQuery, err := sqltemplate.Execute(tmpl, req)
if err != nil {
return nil, fmt.Errorf("execute template %q: %w", tmpl.Name(), err)
}
q := rawQuery
// if false {
// pretty := sqltemplate.RemoveEmptyLines(rawQuery)
// fmt.Printf("DASHBOARD QUERY: %s [%+v] // %+v\n", pretty, req.GetArgs(), query)
// }
rows, err := sql.DB.GetSqlxSession().Query(ctx, q, req.GetArgs()...)
if err != nil {
if rows != nil {
_ = rows.Close()
}
rows = nil
}
return &rowsWrapper{
rows: rows,
a: a,
history: query.GetHistory,
// This looks up rules from the permissions on a user
canReadDashboard: func(scopes ...string) bool {
return true // ???
},
// accesscontrol.Checker(user, dashboards.ActionDashboardsRead),
}, err
}
var _ resource.ListIterator = (*rowsWrapper)(nil)
type rowsWrapper struct {
a *dashboardSqlAccess
rows *sql.Rows
history bool
count int
canReadDashboard func(scopes ...string) bool
// Current
row *dashboardRow
err error
// max 100 rejected?
rejected []dashboardRow
}
func (a *dashboardSqlAccess) GetResourceStats(ctx context.Context, namespace string, minCount int) ([]resource.ResourceStats, error) {
return nil, fmt.Errorf("not implemented")
}
func (r *rowsWrapper) Close() error {
if r.rows == nil {
return nil
}
return r.rows.Close()
}
func (r *rowsWrapper) Next() bool {
if r.err != nil {
return false
}
var err error
// breaks after first readable value
for r.rows.Next() {
r.count++
r.row, err = r.a.scanRow(r.rows, r.history)
if err != nil {
if len(r.rejected) > 1000 || r.row == nil {
r.err = fmt.Errorf("too many rejected rows (%d) %w", len(r.rejected), err)
return false
}
r.rejected = append(r.rejected, *r.row)
continue
}
if r.row != nil {
d := r.row
// 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
}
// returns the first visible dashboard
return true
}
}
return false
}
// ContinueToken implements resource.ListIterator.
func (r *rowsWrapper) ContinueToken() string {
return r.row.token.String()
}
// ContinueTokenWithCurrentRV implements resource.ListIterator.
func (r *rowsWrapper) ContinueTokenWithCurrentRV() string {
return r.row.token.String()
}
// Error implements resource.ListIterator.
func (r *rowsWrapper) Error() error {
return r.err
}
// Name implements resource.ListIterator.
func (r *rowsWrapper) Name() string {
return r.row.Dash.Name
}
// Namespace implements resource.ListIterator.
func (r *rowsWrapper) Namespace() string {
return r.row.Dash.Namespace
}
// ResourceVersion implements resource.ListIterator.
func (r *rowsWrapper) ResourceVersion() int64 {
return r.row.RV
}
func (r *rowsWrapper) Folder() string {
return r.row.FolderUID
}
// Value implements resource.ListIterator.
func (r *rowsWrapper) Value() []byte {
b, err := json.Marshal(r.row.Dash)
r.err = err
return b
}
func (a *dashboardSqlAccess) scanRow(rows *sql.Rows, history bool) (*dashboardRow, error) {
dash := &dashboard.Dashboard{
TypeMeta: dashboard.DashboardResourceInfo.TypeMeta(),
ObjectMeta: metav1.ObjectMeta{Annotations: make(map[string]string)},
}
row := &dashboardRow{Dash: dash}
var dashboard_id int64
var orgId int64
var folder_uid sql.NullString
var updated time.Time
var updatedBy sql.NullString
var updatedByID sql.NullInt64
var deleted sql.NullTime
var created time.Time
var createdBy sql.NullString
var createdByID sql.NullInt64
var message sql.NullString
var plugin_id sql.NullString
var origin_name sql.NullString
var origin_path sql.NullString
var origin_ts sql.NullInt64
var origin_hash sql.NullString
var data []byte // the dashboard JSON
var version int64
err := rows.Scan(&orgId, &dashboard_id, &dash.Name, &folder_uid,
&deleted, &plugin_id,
&origin_name, &origin_path, &origin_hash, &origin_ts,
&created, &createdBy, &createdByID,
&updated, &updatedBy, &updatedByID,
&version, &message, &data,
)
row.token = &continueToken{orgId: orgId, id: dashboard_id}
// when listing from the history table, we want to use the version as the ID to continue from
if history {
row.token.id = version
}
if err == nil {
row.RV = version
dash.ResourceVersion = fmt.Sprintf("%d", row.RV)
dash.Namespace = a.namespacer(orgId)
dash.UID = gapiutil.CalculateClusterWideUID(dash)
dash.SetCreationTimestamp(metav1.NewTime(created))
meta, err := utils.MetaAccessor(dash)
if err != nil {
return nil, err
}
meta.SetUpdatedTimestamp(&updated)
meta.SetCreatedBy(getUserID(createdBy, createdByID))
meta.SetUpdatedBy(getUserID(updatedBy, updatedByID))
meta.SetDeprecatedInternalID(dashboard_id) //nolint:staticcheck
meta.SetGeneration(version)
if deleted.Valid {
meta.SetDeletionTimestamp(ptr.To(metav1.NewTime(deleted.Time)))
meta.SetGeneration(utils.DeletedGeneration)
}
if message.String != "" {
if len(message.String) > 500 {
message.String = message.String[0:490] + "..."
}
meta.SetMessage(message.String)
}
if folder_uid.String != "" {
meta.SetFolder(folder_uid.String)
row.FolderUID = folder_uid.String
}
if origin_name.String != "" {
ts := time.Unix(origin_ts.Int64, 0)
repo := &utils.ResourceRepositoryInfo{
Name: dashboard.ProvisionedFileNameWithPrefix(origin_name.String),
Hash: origin_hash.String,
Timestamp: &ts,
}
// if the reader cannot be found, it may be an orphaned provisioned dashboard
resolvedPath := a.provisioning.GetDashboardProvisionerResolvedPath(origin_name.String)
if resolvedPath != "" {
originPath, err := filepath.Rel(
resolvedPath,
origin_path.String,
)
if err != nil {
return nil, err
}
repo.Path = originPath
}
meta.SetRepositoryInfo(repo)
} else if plugin_id.String != "" {
meta.SetRepositoryInfo(&utils.ResourceRepositoryInfo{
Name: dashboard.PluginIDRepoName,
Path: plugin_id.String,
})
}
if len(data) > 0 {
err = dash.Spec.UnmarshalJSON(data)
if err != nil {
return row, fmt.Errorf("JSON unmarshal error for: %s // %w", dash.Name, err)
}
}
dash.Spec.Remove("id")
}
return row, err
}
func getUserID(v sql.NullString, id sql.NullInt64) string {
if v.Valid && v.String != "" {
return claims.NewTypeID(claims.TypeUser, v.String)
}
if id.Valid && id.Int64 == -1 {
return claims.NewTypeID(claims.TypeProvisioning, "")
}
return ""
}
// DeleteDashboard implements DashboardAccess.
func (a *dashboardSqlAccess) DeleteDashboard(ctx context.Context, orgId int64, uid string) (*dashboard.Dashboard, bool, error) {
dash, _, err := a.GetDashboard(ctx, orgId, uid, 0)
if err != nil {
return nil, false, err
}
if a.softDelete {
err = a.dashStore.SoftDeleteDashboard(ctx, orgId, uid)
if err == nil && dash != nil {
now := metav1.NewTime(time.Now())
dash.DeletionTimestamp = &now
return dash, true, err
}
return dash, false, err
}
err = a.dashStore.DeleteDashboard(ctx, &dashboards.DeleteDashboardCommand{
OrgID: orgId,
UID: uid,
})
if err != nil {
return nil, false, err
}
return dash, true, nil
}
// SaveDashboard implements DashboardAccess.
func (a *dashboardSqlAccess) SaveDashboard(ctx context.Context, orgId int64, dash *dashboard.Dashboard) (*dashboard.Dashboard, bool, error) {
created := false
user, ok := claims.AuthInfoFrom(ctx)
if !ok || user == nil {
return nil, created, fmt.Errorf("no user found in context")
}
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")
}
var userID int64
if claims.IsIdentityType(user.GetIdentityType(), claims.TypeUser) {
var err error
userID, err = identity.UserIdentifier(user.GetSubject())
if err != nil {
return nil, false, err
}
}
meta, err := utils.MetaAccessor(dash)
if err != nil {
return nil, false, err
}
out, err := a.dashStore.SaveDashboard(ctx, dashboards.SaveDashboardCommand{
OrgID: orgId,
Message: meta.GetMessage(),
PluginID: dashboard.GetPluginIDFromMeta(meta),
Dashboard: simplejson.NewFromAny(dash.Spec.UnstructuredContent()),
FolderUID: meta.GetFolder(),
Overwrite: true, // already passed the revisionVersion checks!
UserID: 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, 0)
if err != nil {
return nil, false, err
}
// stash the raw value in context (if requested)
finalMeta, err := utils.MetaAccessor(dash)
if err != nil {
return nil, false, err
}
access := GetLegacyAccess(ctx)
if access != nil {
access.DashboardID = finalMeta.GetDeprecatedInternalID() // nolint:staticcheck
}
return dash, created, err
}
func (a *dashboardSqlAccess) GetLibraryPanels(ctx context.Context, query LibraryPanelQuery) (*dashboard.LibraryPanelList, error) {
limit := int(query.Limit)
query.Limit += 1 // for continue
if query.OrgID == 0 {
return nil, fmt.Errorf("expected non zero orgID")
}
sqlx, err := a.sql(ctx)
if err != nil {
return nil, err
}
req := newLibraryQueryReq(sqlx, &query)
rawQuery, err := sqltemplate.Execute(sqlQueryPanels, req)
if err != nil {
return nil, fmt.Errorf("execute template %q: %w", sqlQueryPanels.Name(), err)
}
q := rawQuery
res := &dashboard.LibraryPanelList{}
rows, err := sqlx.DB.GetSqlxSession().Query(ctx, q, req.GetArgs()...)
defer func() {
if rows != nil {
_ = rows.Close()
}
}()
if err != nil {
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,
)
if err != nil {
return res, err
}
lastID = p.ID
item := dashboard.LibraryPanel{
TypeMeta: metav1.TypeMeta{
APIVersion: fmt.Sprintf("%s/%s", dashboard.GROUP, "v0alpha1"),
Kind: "LibraryPanel",
},
ObjectMeta: metav1.ObjectMeta{
Name: p.UID,
CreationTimestamp: metav1.NewTime(p.Created),
ResourceVersion: strconv.FormatInt(p.Updated.UnixMilli(), 10),
},
Spec: dashboard.LibraryPanelSpec{},
}
status := &dashboard.LibraryPanelStatus{
Missing: v0alpha1.Unstructured{},
}
err = json.Unmarshal(p.Model, &item.Spec)
if err != nil {
return nil, err
}
err = json.Unmarshal(p.Model, &status.Missing.Object)
if err != nil {
return nil, 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)
}
res.Items = append(res.Items, item)
if len(res.Items) > limit {
res.Continue = strconv.FormatInt(lastID, 10)
break
}
}
if query.UID == "" {
rv, err := sqlx.GetResourceVersion(ctx, "library_element", "updated")
if err == nil {
res.ResourceVersion = strconv.FormatInt(rv, 10)
}
}
return res, err
}