Stars: Move stars from preferences apiserver to a new collections apiserver (#114006)
This commit is contained in:
@@ -0,0 +1,35 @@
|
||||
package collections
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"k8s.io/apiserver/pkg/admission"
|
||||
|
||||
collections "github.com/grafana/grafana/apps/collections/pkg/apis/collections/v1alpha1"
|
||||
)
|
||||
|
||||
func (b *APIBuilder) Mutate(ctx context.Context, a admission.Attributes, o admission.ObjectInterfaces) (err error) {
|
||||
switch a.GetOperation() {
|
||||
case admission.Create, admission.Update:
|
||||
// ignore anything that is not CREATE | UPDATE
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
|
||||
obj := a.GetObject()
|
||||
if obj == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
switch a.GetResource().Resource {
|
||||
case "stars":
|
||||
stars, ok := obj.(*collections.Stars)
|
||||
if !ok {
|
||||
return fmt.Errorf("expected stars object: (%T)", obj)
|
||||
}
|
||||
stars.Spec.Normalize()
|
||||
return nil
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package legacy
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"fmt"
|
||||
"text/template"
|
||||
|
||||
"github.com/grafana/grafana/pkg/storage/legacysql"
|
||||
"github.com/grafana/grafana/pkg/storage/unified/sql/sqltemplate"
|
||||
)
|
||||
|
||||
// Templates setup.
|
||||
var (
|
||||
//go:embed *.sql
|
||||
sqlTemplatesFS embed.FS
|
||||
|
||||
sqlTemplates = template.Must(template.New("sql").ParseFS(sqlTemplatesFS, `*.sql`))
|
||||
)
|
||||
|
||||
func mustTemplate(filename string) *template.Template {
|
||||
if t := sqlTemplates.Lookup(filename); t != nil {
|
||||
return t
|
||||
}
|
||||
panic(fmt.Sprintf("template file not found: %s", filename))
|
||||
}
|
||||
|
||||
// Templates.
|
||||
var (
|
||||
sqlDashboardStarsQuery = mustTemplate("sql_dashboard_stars.sql")
|
||||
sqlDashboardStarsRV = mustTemplate("sql_dashboard_stars_rv.sql")
|
||||
)
|
||||
|
||||
type starQuery struct {
|
||||
sqltemplate.SQLTemplate
|
||||
|
||||
OrgID int64 // >= 1 if UserID != ""
|
||||
UserUID string
|
||||
UserID int64 // for stars
|
||||
QueryUIDs []string
|
||||
QueryUID string
|
||||
|
||||
StarTable string
|
||||
UserTable string
|
||||
QueryHistoryStarsTable string
|
||||
QueryHistoryTable string
|
||||
}
|
||||
|
||||
func (r starQuery) Validate() error {
|
||||
if r.UserUID != "" && r.OrgID < 1 {
|
||||
return fmt.Errorf("requests with a userid, must include an orgID")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func newStarQueryReq(sql *legacysql.LegacyDatabaseHelper, user string, orgId int64) starQuery {
|
||||
return starQuery{
|
||||
SQLTemplate: sqltemplate.New(sql.DialectForDriver()),
|
||||
|
||||
UserUID: user,
|
||||
OrgID: orgId,
|
||||
|
||||
StarTable: sql.Table("star"),
|
||||
UserTable: sql.Table("user"),
|
||||
QueryHistoryStarsTable: sql.Table("query_history_star"),
|
||||
QueryHistoryTable: sql.Table("query_history"),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package legacy
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"text/template"
|
||||
|
||||
"github.com/grafana/grafana/pkg/storage/legacysql"
|
||||
"github.com/grafana/grafana/pkg/storage/unified/sql/sqltemplate"
|
||||
"github.com/grafana/grafana/pkg/storage/unified/sql/sqltemplate/mocks"
|
||||
)
|
||||
|
||||
func TestStarsQueries(t *testing.T) {
|
||||
// prefix tables with grafana
|
||||
nodb := &legacysql.LegacyDatabaseHelper{
|
||||
Table: func(n string) string {
|
||||
return "grafana." + n
|
||||
},
|
||||
}
|
||||
|
||||
getStarQuery := func(orgId int64, user string) sqltemplate.SQLTemplate {
|
||||
v := newStarQueryReq(nodb, user, orgId)
|
||||
v.SQLTemplate = mocks.NewTestingSQLTemplate()
|
||||
return &v
|
||||
}
|
||||
|
||||
mocks.CheckQuerySnapshots(t, mocks.TemplateTestSetup{
|
||||
RootDir: "testdata",
|
||||
SQLTemplatesFS: sqlTemplatesFS,
|
||||
Templates: map[*template.Template][]mocks.TemplateTestCase{
|
||||
sqlDashboardStarsQuery: {
|
||||
{
|
||||
Name: "all",
|
||||
Data: getStarQuery(0, ""),
|
||||
},
|
||||
{
|
||||
Name: "org",
|
||||
Data: getStarQuery(3, ""),
|
||||
},
|
||||
{
|
||||
Name: "user",
|
||||
Data: getStarQuery(3, "abc"),
|
||||
},
|
||||
},
|
||||
sqlDashboardStarsRV: {
|
||||
{
|
||||
Name: "get",
|
||||
Data: getStarQuery(0, ""),
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
package legacy
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/grafana/grafana/pkg/storage/legacysql"
|
||||
"github.com/grafana/grafana/pkg/storage/unified/sql/sqltemplate"
|
||||
)
|
||||
|
||||
type dashboardStars struct {
|
||||
OrgID int64
|
||||
UserUID string
|
||||
First int64
|
||||
Last int64
|
||||
|
||||
Dashboards []string
|
||||
}
|
||||
|
||||
type LegacySQL struct {
|
||||
db legacysql.LegacyDatabaseProvider
|
||||
startup time.Time
|
||||
}
|
||||
|
||||
func NewLegacySQL(db legacysql.LegacyDatabaseProvider) *LegacySQL {
|
||||
return &LegacySQL{db: db, startup: time.Now()}
|
||||
}
|
||||
|
||||
// NOTE: this does not support paging -- lets check if that will be a problem in cloud
|
||||
func (s *LegacySQL) getDashboardStars(ctx context.Context, orgId int64, user string) ([]dashboardStars, int64, error) {
|
||||
var max sql.NullString
|
||||
sql, err := s.db(ctx)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
req := newStarQueryReq(sql, user, orgId)
|
||||
|
||||
q, err := sqltemplate.Execute(sqlDashboardStarsQuery, req)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("execute template %q: %w", sqlDashboardStarsQuery.Name(), err)
|
||||
}
|
||||
|
||||
sess := sql.DB.GetSqlxSession()
|
||||
rows, err := sess.Query(ctx, q, req.GetArgs()...)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
defer func() {
|
||||
if rows != nil {
|
||||
_ = rows.Close()
|
||||
}
|
||||
}()
|
||||
|
||||
stars := []dashboardStars{}
|
||||
current := &dashboardStars{}
|
||||
var orgID int64
|
||||
var userUID string
|
||||
var dashboardUID string
|
||||
var updated time.Time
|
||||
|
||||
for rows.Next() {
|
||||
err := rows.Scan(&orgID, &userUID, &dashboardUID, &updated)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
if orgID != current.OrgID || userUID != current.UserUID {
|
||||
if current.UserUID != "" {
|
||||
stars = append(stars, *current)
|
||||
}
|
||||
current = &dashboardStars{
|
||||
OrgID: orgID,
|
||||
UserUID: userUID,
|
||||
}
|
||||
}
|
||||
ts := updated.UnixMilli()
|
||||
if ts > current.Last {
|
||||
current.Last = ts
|
||||
}
|
||||
if ts < current.First || current.First == 0 {
|
||||
current.First = ts
|
||||
}
|
||||
current.Dashboards = append(current.Dashboards, dashboardUID)
|
||||
}
|
||||
|
||||
// Add the last value
|
||||
if current.UserUID != "" {
|
||||
stars = append(stars, *current)
|
||||
}
|
||||
|
||||
// Find the RV unless it is a user query
|
||||
if userUID == "" {
|
||||
req.Reset()
|
||||
q, err = sqltemplate.Execute(sqlDashboardStarsRV, req)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("execute template %q: %w", sqlDashboardStarsRV.Name(), err)
|
||||
}
|
||||
err = sess.Get(ctx, &max, q)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("unable to get RV %w", err)
|
||||
}
|
||||
if max.Valid && max.String != "" {
|
||||
t, _ := time.Parse(time.RFC3339, max.String)
|
||||
if !t.IsZero() {
|
||||
updated = t
|
||||
}
|
||||
} else {
|
||||
updated = s.startup
|
||||
}
|
||||
}
|
||||
|
||||
return stars, updated.UnixMilli(), err
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
SELECT s.org_id, u.uid as user_uid, s.dashboard_uid, s.updated
|
||||
FROM {{ .Ident .StarTable }} as s
|
||||
JOIN {{ .Ident .UserTable }} as u ON s.user_id = u.id
|
||||
{{ if ge .OrgID 1 }}
|
||||
WHERE s.org_id = {{ .Arg .OrgID }}
|
||||
{{ if .UserUID }}
|
||||
AND u.uid = {{ .Arg .UserUID }}
|
||||
{{ end }}{{ end }}
|
||||
ORDER BY
|
||||
s.org_id asc, s.user_id asc, s.updated asc
|
||||
@@ -0,0 +1 @@
|
||||
SELECT MAX(updated) FROM {{ .Ident .StarTable }}
|
||||
@@ -0,0 +1,307 @@
|
||||
package legacy
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
apiserrors "k8s.io/apimachinery/pkg/api/errors"
|
||||
"k8s.io/apimachinery/pkg/apis/meta/internalversion"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/runtime"
|
||||
"k8s.io/apimachinery/pkg/runtime/schema"
|
||||
"k8s.io/apiserver/pkg/registry/rest"
|
||||
"k8s.io/utils/ptr"
|
||||
|
||||
authlib "github.com/grafana/authlib/types"
|
||||
collections "github.com/grafana/grafana/apps/collections/pkg/apis/collections/v1alpha1"
|
||||
dashboardsV1 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v1beta1"
|
||||
"github.com/grafana/grafana/pkg/apimachinery/identity"
|
||||
"github.com/grafana/grafana/pkg/registry/apis/preferences/utils"
|
||||
"github.com/grafana/grafana/pkg/services/apiserver/endpoints/request"
|
||||
"github.com/grafana/grafana/pkg/services/star"
|
||||
"github.com/grafana/grafana/pkg/services/user"
|
||||
)
|
||||
|
||||
var (
|
||||
_ rest.Scoper = (*DashboardStarsStorage)(nil)
|
||||
_ rest.SingularNameProvider = (*DashboardStarsStorage)(nil)
|
||||
_ rest.Getter = (*DashboardStarsStorage)(nil)
|
||||
_ rest.Lister = (*DashboardStarsStorage)(nil)
|
||||
_ rest.Storage = (*DashboardStarsStorage)(nil)
|
||||
_ rest.Creater = (*DashboardStarsStorage)(nil)
|
||||
_ rest.Updater = (*DashboardStarsStorage)(nil)
|
||||
_ rest.GracefulDeleter = (*DashboardStarsStorage)(nil)
|
||||
_ rest.CollectionDeleter = (*DashboardStarsStorage)(nil)
|
||||
)
|
||||
|
||||
func NewDashboardStarsStorage(
|
||||
stars star.Service,
|
||||
users user.Service,
|
||||
namespacer request.NamespaceMapper,
|
||||
sql *LegacySQL,
|
||||
) *DashboardStarsStorage {
|
||||
return &DashboardStarsStorage{
|
||||
stars: stars,
|
||||
users: users,
|
||||
namespacer: namespacer,
|
||||
sql: sql,
|
||||
tableConverter: collections.StarsResourceInfo.TableConverter(),
|
||||
}
|
||||
}
|
||||
|
||||
type DashboardStarsStorage struct {
|
||||
namespacer request.NamespaceMapper
|
||||
tableConverter rest.TableConvertor
|
||||
sql *LegacySQL
|
||||
stars star.Service
|
||||
users user.Service
|
||||
}
|
||||
|
||||
func (s *DashboardStarsStorage) New() runtime.Object {
|
||||
return collections.StarsKind().ZeroValue()
|
||||
}
|
||||
|
||||
func (s *DashboardStarsStorage) Destroy() {}
|
||||
|
||||
func (s *DashboardStarsStorage) NamespaceScoped() bool {
|
||||
return true // namespace == org
|
||||
}
|
||||
|
||||
func (s *DashboardStarsStorage) GetSingularName() string {
|
||||
return strings.ToLower(collections.StarsKind().Kind())
|
||||
}
|
||||
|
||||
func (s *DashboardStarsStorage) NewList() runtime.Object {
|
||||
return collections.StarsKind().ZeroListValue()
|
||||
}
|
||||
|
||||
func (s *DashboardStarsStorage) ConvertToTable(ctx context.Context, object runtime.Object, tableOptions runtime.Object) (*metav1.Table, error) {
|
||||
return s.tableConverter.ConvertToTable(ctx, object, tableOptions)
|
||||
}
|
||||
|
||||
func (s *DashboardStarsStorage) List(ctx context.Context, options *internalversion.ListOptions) (runtime.Object, error) {
|
||||
ns, err := request.NamespaceInfoFrom(ctx, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if ns.Value == "" {
|
||||
return nil, fmt.Errorf("cross cluster listing is not supported")
|
||||
}
|
||||
|
||||
userInfo, err := identity.GetRequester(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
user := userInfo.GetIdentifier()
|
||||
if userInfo.GetIdentityType() == authlib.TypeAccessPolicy {
|
||||
user = "" // can see everything
|
||||
}
|
||||
|
||||
list := &collections.StarsList{}
|
||||
found, rv, err := s.sql.getDashboardStars(ctx, ns.OrgID, user)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, v := range found {
|
||||
list.Items = append(list.Items,
|
||||
asStarsResource(s.namespacer(v.OrgID), &v))
|
||||
}
|
||||
if rv > 0 {
|
||||
list.ResourceVersion = strconv.FormatInt(rv, 10)
|
||||
}
|
||||
return list, nil
|
||||
}
|
||||
|
||||
func getNamespaceAndOwner(ctx context.Context, name string) (authlib.NamespaceInfo, utils.OwnerReference, error) {
|
||||
info, err := request.NamespaceInfoFrom(ctx, true)
|
||||
if err != nil {
|
||||
return info, utils.OwnerReference{}, err
|
||||
}
|
||||
owner, ok := utils.ParseOwnerFromName(name)
|
||||
if !ok {
|
||||
return info, owner, fmt.Errorf("invalid name %w", err)
|
||||
}
|
||||
if owner.Owner != utils.UserResourceOwner {
|
||||
return info, owner, fmt.Errorf("expecting name with prefix: %s-", utils.UserResourceOwner)
|
||||
}
|
||||
return info, owner, nil
|
||||
}
|
||||
|
||||
func (s *DashboardStarsStorage) Get(ctx context.Context, name string, options *metav1.GetOptions) (runtime.Object, error) {
|
||||
ns, owner, err := getNamespaceAndOwner(ctx, name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
found, _, err := s.sql.getDashboardStars(ctx, ns.OrgID, owner.Identifier)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(found) == 0 || len(found[0].Dashboards) == 0 {
|
||||
return nil, apiserrors.NewNotFound(collections.StarsResourceInfo.GroupResource(), name)
|
||||
}
|
||||
obj := asStarsResource(ns.Value, &found[0])
|
||||
return &obj, nil
|
||||
}
|
||||
|
||||
func getStars(stars *collections.Stars, gk schema.GroupKind) []string {
|
||||
if stars == nil || len(stars.Spec.Resource) == 0 {
|
||||
return []string{}
|
||||
}
|
||||
for _, r := range stars.Spec.Resource {
|
||||
if r.Group == gk.Group && r.Kind == gk.Kind {
|
||||
return r.Names
|
||||
}
|
||||
}
|
||||
return []string{}
|
||||
}
|
||||
|
||||
// Create implements rest.Creater.
|
||||
func (s *DashboardStarsStorage) write(ctx context.Context, obj *collections.Stars) (runtime.Object, error) {
|
||||
ns, owner, err := getNamespaceAndOwner(ctx, obj.Name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
user, err := s.users.GetByUID(ctx, &user.GetUserByUIDQuery{
|
||||
UID: owner.Identifier,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if user.OrgID != ns.OrgID {
|
||||
return nil, fmt.Errorf("namespace mismatch")
|
||||
}
|
||||
|
||||
stars := getStars(obj, schema.GroupKind{Group: "dashboard.grafana.app", Kind: "Dashboard"})
|
||||
if len(stars) == 0 {
|
||||
err = s.stars.DeleteByUser(ctx, user.ID)
|
||||
return &collections.Stars{ObjectMeta: metav1.ObjectMeta{
|
||||
Name: obj.Name,
|
||||
Namespace: obj.Namespace,
|
||||
DeletionTimestamp: ptr.To(metav1.Now()),
|
||||
}}, err
|
||||
}
|
||||
|
||||
current, _, err := s.sql.getDashboardStars(ctx, ns.OrgID, owner.Identifier)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
changed := false
|
||||
now := time.Now()
|
||||
randID := now.UnixNano() + rand.Int63n(5000)
|
||||
previous := make(map[string]bool)
|
||||
if len(current) > 0 {
|
||||
for _, v := range current[0].Dashboards {
|
||||
previous[v] = true
|
||||
}
|
||||
}
|
||||
for _, dashboard := range stars {
|
||||
if previous[dashboard] {
|
||||
delete(previous, dashboard)
|
||||
continue // nothing needed
|
||||
}
|
||||
err = s.stars.Add(ctx, &star.StarDashboardCommand{
|
||||
UserID: user.ID,
|
||||
OrgID: user.OrgID,
|
||||
DashboardUID: dashboard,
|
||||
DashboardID: randID,
|
||||
Updated: now,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
changed = true
|
||||
randID++
|
||||
}
|
||||
|
||||
for k := range previous {
|
||||
err = s.stars.Delete(ctx, &star.UnstarDashboardCommand{
|
||||
UserID: user.ID,
|
||||
OrgID: user.OrgID,
|
||||
DashboardUID: k,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
changed = true
|
||||
}
|
||||
|
||||
if changed {
|
||||
return s.Get(ctx, obj.Name, &metav1.GetOptions{})
|
||||
}
|
||||
return obj, nil // nothing required
|
||||
}
|
||||
|
||||
// Create implements rest.Creater.
|
||||
func (s *DashboardStarsStorage) Create(ctx context.Context, obj runtime.Object, createValidation rest.ValidateObjectFunc, options *metav1.CreateOptions) (runtime.Object, error) {
|
||||
stars, ok := obj.(*collections.Stars)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("expected stars object")
|
||||
}
|
||||
|
||||
return s.write(ctx, stars)
|
||||
}
|
||||
|
||||
// Update implements rest.Updater.
|
||||
func (s *DashboardStarsStorage) Update(ctx context.Context, name string, objInfo rest.UpdatedObjectInfo, createValidation rest.ValidateObjectFunc, updateValidation rest.ValidateObjectUpdateFunc, forceAllowCreate bool, options *metav1.UpdateOptions) (runtime.Object, bool, error) {
|
||||
old, err := s.Get(ctx, name, &metav1.GetOptions{})
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
|
||||
obj, err := objInfo.UpdatedObject(ctx, old)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
|
||||
stars, ok := obj.(*collections.Stars)
|
||||
if !ok {
|
||||
return nil, false, fmt.Errorf("expected stars object")
|
||||
}
|
||||
|
||||
obj, err = s.write(ctx, stars)
|
||||
return obj, false, err
|
||||
}
|
||||
|
||||
// Delete implements rest.GracefulDeleter.
|
||||
func (s *DashboardStarsStorage) Delete(ctx context.Context, name string, deleteValidation rest.ValidateObjectFunc, options *metav1.DeleteOptions) (runtime.Object, bool, error) {
|
||||
obj, err := s.write(ctx, &collections.Stars{ObjectMeta: metav1.ObjectMeta{Name: name}})
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
return obj, true, err
|
||||
}
|
||||
|
||||
// DeleteCollection implements rest.CollectionDeleter.
|
||||
func (s *DashboardStarsStorage) DeleteCollection(ctx context.Context, deleteValidation rest.ValidateObjectFunc, options *metav1.DeleteOptions, listOptions *internalversion.ListOptions) (runtime.Object, error) {
|
||||
return nil, fmt.Errorf("not implemented yet")
|
||||
}
|
||||
|
||||
func asStarsResource(ns string, v *dashboardStars) collections.Stars {
|
||||
stars := collections.Stars{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: fmt.Sprintf("user-%s", v.UserUID),
|
||||
Namespace: ns,
|
||||
ResourceVersion: strconv.FormatInt(v.Last, 10),
|
||||
CreationTimestamp: metav1.NewTime(time.UnixMilli(v.First)),
|
||||
},
|
||||
Spec: collections.StarsSpec{
|
||||
Resource: []collections.StarsResource{{
|
||||
Group: dashboardsV1.APIGroup,
|
||||
Kind: "Dashboard",
|
||||
Names: v.Dashboards,
|
||||
}},
|
||||
},
|
||||
}
|
||||
stars.Spec.Normalize()
|
||||
return stars
|
||||
}
|
||||
Vendored
Executable
+5
@@ -0,0 +1,5 @@
|
||||
SELECT s.org_id, u.uid as user_uid, s.dashboard_uid, s.updated
|
||||
FROM `grafana`.`star` as s
|
||||
JOIN `grafana`.`user` as u ON s.user_id = u.id
|
||||
ORDER BY
|
||||
s.org_id asc, s.user_id asc, s.updated asc
|
||||
Vendored
Executable
+6
@@ -0,0 +1,6 @@
|
||||
SELECT s.org_id, u.uid as user_uid, s.dashboard_uid, s.updated
|
||||
FROM `grafana`.`star` as s
|
||||
JOIN `grafana`.`user` as u ON s.user_id = u.id
|
||||
WHERE s.org_id = 3
|
||||
ORDER BY
|
||||
s.org_id asc, s.user_id asc, s.updated asc
|
||||
Vendored
Executable
+7
@@ -0,0 +1,7 @@
|
||||
SELECT s.org_id, u.uid as user_uid, s.dashboard_uid, s.updated
|
||||
FROM `grafana`.`star` as s
|
||||
JOIN `grafana`.`user` as u ON s.user_id = u.id
|
||||
WHERE s.org_id = 3
|
||||
AND u.uid = 'abc'
|
||||
ORDER BY
|
||||
s.org_id asc, s.user_id asc, s.updated asc
|
||||
Vendored
Executable
+1
@@ -0,0 +1 @@
|
||||
SELECT MAX(updated) FROM `grafana`.`star`
|
||||
Vendored
Executable
+5
@@ -0,0 +1,5 @@
|
||||
SELECT s.org_id, u.uid as user_uid, s.dashboard_uid, s.updated
|
||||
FROM "grafana"."star" as s
|
||||
JOIN "grafana"."user" as u ON s.user_id = u.id
|
||||
ORDER BY
|
||||
s.org_id asc, s.user_id asc, s.updated asc
|
||||
Vendored
Executable
+6
@@ -0,0 +1,6 @@
|
||||
SELECT s.org_id, u.uid as user_uid, s.dashboard_uid, s.updated
|
||||
FROM "grafana"."star" as s
|
||||
JOIN "grafana"."user" as u ON s.user_id = u.id
|
||||
WHERE s.org_id = 3
|
||||
ORDER BY
|
||||
s.org_id asc, s.user_id asc, s.updated asc
|
||||
Vendored
Executable
+7
@@ -0,0 +1,7 @@
|
||||
SELECT s.org_id, u.uid as user_uid, s.dashboard_uid, s.updated
|
||||
FROM "grafana"."star" as s
|
||||
JOIN "grafana"."user" as u ON s.user_id = u.id
|
||||
WHERE s.org_id = 3
|
||||
AND u.uid = 'abc'
|
||||
ORDER BY
|
||||
s.org_id asc, s.user_id asc, s.updated asc
|
||||
Vendored
Executable
+1
@@ -0,0 +1 @@
|
||||
SELECT MAX(updated) FROM "grafana"."star"
|
||||
Vendored
Executable
+5
@@ -0,0 +1,5 @@
|
||||
SELECT s.org_id, u.uid as user_uid, s.dashboard_uid, s.updated
|
||||
FROM "grafana"."star" as s
|
||||
JOIN "grafana"."user" as u ON s.user_id = u.id
|
||||
ORDER BY
|
||||
s.org_id asc, s.user_id asc, s.updated asc
|
||||
Vendored
Executable
+6
@@ -0,0 +1,6 @@
|
||||
SELECT s.org_id, u.uid as user_uid, s.dashboard_uid, s.updated
|
||||
FROM "grafana"."star" as s
|
||||
JOIN "grafana"."user" as u ON s.user_id = u.id
|
||||
WHERE s.org_id = 3
|
||||
ORDER BY
|
||||
s.org_id asc, s.user_id asc, s.updated asc
|
||||
Vendored
Executable
+7
@@ -0,0 +1,7 @@
|
||||
SELECT s.org_id, u.uid as user_uid, s.dashboard_uid, s.updated
|
||||
FROM "grafana"."star" as s
|
||||
JOIN "grafana"."user" as u ON s.user_id = u.id
|
||||
WHERE s.org_id = 3
|
||||
AND u.uid = 'abc'
|
||||
ORDER BY
|
||||
s.org_id asc, s.user_id asc, s.updated asc
|
||||
Vendored
Executable
+1
@@ -0,0 +1 @@
|
||||
SELECT MAX(updated) FROM "grafana"."star"
|
||||
@@ -0,0 +1,178 @@
|
||||
package collections
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/runtime"
|
||||
"k8s.io/apimachinery/pkg/runtime/schema"
|
||||
"k8s.io/apiserver/pkg/authorization/authorizer"
|
||||
"k8s.io/apiserver/pkg/registry/rest"
|
||||
genericapiserver "k8s.io/apiserver/pkg/server"
|
||||
"k8s.io/kube-openapi/pkg/common"
|
||||
"k8s.io/kube-openapi/pkg/spec3"
|
||||
"k8s.io/kube-openapi/pkg/validation/spec"
|
||||
|
||||
collections "github.com/grafana/grafana/apps/collections/pkg/apis/collections/v1alpha1"
|
||||
grafanaregistry "github.com/grafana/grafana/pkg/apiserver/registry/generic"
|
||||
grafanarest "github.com/grafana/grafana/pkg/apiserver/rest"
|
||||
"github.com/grafana/grafana/pkg/infra/db"
|
||||
"github.com/grafana/grafana/pkg/registry/apis/collections/legacy"
|
||||
"github.com/grafana/grafana/pkg/registry/apis/preferences/utils"
|
||||
"github.com/grafana/grafana/pkg/services/apiserver/builder"
|
||||
"github.com/grafana/grafana/pkg/services/apiserver/endpoints/request"
|
||||
"github.com/grafana/grafana/pkg/services/featuremgmt"
|
||||
"github.com/grafana/grafana/pkg/services/star"
|
||||
"github.com/grafana/grafana/pkg/services/user"
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
"github.com/grafana/grafana/pkg/storage/legacysql"
|
||||
)
|
||||
|
||||
var (
|
||||
_ builder.APIGroupBuilder = (*APIBuilder)(nil)
|
||||
_ builder.APIGroupMutation = (*APIBuilder)(nil)
|
||||
)
|
||||
|
||||
type APIBuilder struct {
|
||||
authorizer authorizer.Authorizer
|
||||
legacyStars *legacy.DashboardStarsStorage
|
||||
}
|
||||
|
||||
func RegisterAPIService(
|
||||
cfg *setting.Cfg,
|
||||
features featuremgmt.FeatureToggles,
|
||||
db db.DB,
|
||||
stars star.Service,
|
||||
users user.Service,
|
||||
apiregistration builder.APIRegistrar,
|
||||
) *APIBuilder {
|
||||
// Requires development settings and clearly experimental
|
||||
//nolint:staticcheck // not yet migrated to OpenFeature
|
||||
if !features.IsEnabledGlobally(featuremgmt.FlagGrafanaAPIServerWithExperimentalAPIs) {
|
||||
return nil
|
||||
}
|
||||
|
||||
sql := legacy.NewLegacySQL(legacysql.NewDatabaseProvider(db))
|
||||
builder := &APIBuilder{
|
||||
authorizer: &utils.AuthorizeFromName{
|
||||
Resource: map[string][]utils.ResourceOwner{
|
||||
"stars": {utils.UserResourceOwner},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
namespacer := request.GetNamespaceMapper(cfg)
|
||||
if stars != nil {
|
||||
builder.legacyStars = legacy.NewDashboardStarsStorage(stars, users, namespacer, sql)
|
||||
}
|
||||
|
||||
apiregistration.RegisterAPI(builder)
|
||||
return builder
|
||||
}
|
||||
|
||||
// AllowedV0Alpha1Resources implements builder.APIGroupBuilder.
|
||||
func (b *APIBuilder) AllowedV0Alpha1Resources() []string {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *APIBuilder) GetGroupVersion() schema.GroupVersion {
|
||||
return collections.GroupVersion
|
||||
}
|
||||
|
||||
func (b *APIBuilder) InstallSchema(scheme *runtime.Scheme) error {
|
||||
gv := collections.GroupVersion
|
||||
err := collections.AddToScheme(scheme)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
metav1.AddToGroupVersion(scheme, gv)
|
||||
return scheme.SetVersionPriority(gv)
|
||||
}
|
||||
|
||||
func (b *APIBuilder) UpdateAPIGroupInfo(apiGroupInfo *genericapiserver.APIGroupInfo, opts builder.APIGroupOptions) error {
|
||||
storage := map[string]rest.Storage{}
|
||||
|
||||
// Configure Stars Dual writer
|
||||
resource := collections.StarsResourceInfo
|
||||
var stars grafanarest.Storage
|
||||
stars, err := grafanaregistry.NewRegistryStore(opts.Scheme, resource, opts.OptsGetter)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
stars = &starStorage{Storage: stars} // wrap List so we only return one value
|
||||
if b.legacyStars != nil && opts.DualWriteBuilder != nil {
|
||||
stars, err = opts.DualWriteBuilder(resource.GroupResource(), b.legacyStars, stars)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
storage[resource.StoragePath()] = stars
|
||||
storage[resource.StoragePath("update")] = &starsREST{store: stars}
|
||||
|
||||
apiGroupInfo.VersionedResourcesStorageMap[collections.APIVersion] = storage
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *APIBuilder) GetAuthorizer() authorizer.Authorizer {
|
||||
return b.authorizer
|
||||
}
|
||||
|
||||
func (b *APIBuilder) GetOpenAPIDefinitions() common.GetOpenAPIDefinitions {
|
||||
return collections.GetOpenAPIDefinitions
|
||||
}
|
||||
|
||||
func (b *APIBuilder) PostProcessOpenAPI(oas *spec3.OpenAPI) (*spec3.OpenAPI, error) {
|
||||
oas.Info.Description = "Grafana collections"
|
||||
|
||||
root := "/apis/" + b.GetGroupVersion().String() + "/"
|
||||
updateKey := root + "namespaces/{namespace}/stars/{name}/update"
|
||||
delete(oas.Paths.Paths, updateKey)
|
||||
|
||||
// Add the group/kind/id properties to the path
|
||||
stars, ok := oas.Paths.Paths[updateKey+"/{path}"]
|
||||
if !ok || stars == nil {
|
||||
return nil, fmt.Errorf("unable to find write path")
|
||||
}
|
||||
stars.Parameters = []*spec3.Parameter{
|
||||
stars.Parameters[0], // name
|
||||
stars.Parameters[1], // namespace
|
||||
{
|
||||
ParameterProps: spec3.ParameterProps{
|
||||
Name: "group",
|
||||
In: "path",
|
||||
Example: "dashboard.grafana.app",
|
||||
Description: "API group for stared item",
|
||||
Schema: spec.StringProperty(),
|
||||
Required: true,
|
||||
},
|
||||
}, {
|
||||
ParameterProps: spec3.ParameterProps{
|
||||
Name: "kind",
|
||||
In: "path",
|
||||
Example: "Dashboard",
|
||||
Description: "Kind for stared item",
|
||||
Schema: spec.StringProperty(),
|
||||
Required: true,
|
||||
},
|
||||
}, {
|
||||
ParameterProps: spec3.ParameterProps{
|
||||
Name: "id",
|
||||
In: "path",
|
||||
Example: "",
|
||||
Description: "The k8s name for the selected item",
|
||||
Schema: spec.StringProperty(),
|
||||
Required: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
stars.Put.Description = "Add a starred item"
|
||||
stars.Put.OperationId = "addStar"
|
||||
stars.Delete.Description = "Remove a starred item"
|
||||
stars.Delete.OperationId = "removeStar"
|
||||
|
||||
delete(oas.Paths.Paths, updateKey+"/{path}")
|
||||
oas.Paths.Paths[updateKey+"/{group}/{kind}/{id}"] = stars
|
||||
|
||||
return oas, nil
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package collections
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"k8s.io/apimachinery/pkg/apis/meta/internalversion"
|
||||
v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/runtime"
|
||||
|
||||
authlib "github.com/grafana/authlib/types"
|
||||
collections "github.com/grafana/grafana/apps/collections/pkg/apis/collections/v1alpha1"
|
||||
"github.com/grafana/grafana/pkg/apimachinery/identity"
|
||||
grafanarest "github.com/grafana/grafana/pkg/apiserver/rest"
|
||||
)
|
||||
|
||||
var _ grafanarest.Storage = (*starStorage)(nil)
|
||||
|
||||
type starStorage struct {
|
||||
grafanarest.Storage
|
||||
}
|
||||
|
||||
// When using list, we really just want to get the value for the single user
|
||||
func (s *starStorage) List(ctx context.Context, options *internalversion.ListOptions) (runtime.Object, error) {
|
||||
user, err := identity.GetRequester(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
switch user.GetIdentityType() {
|
||||
case authlib.TypeAnonymous:
|
||||
return s.NewList(), nil
|
||||
|
||||
// Get the single user stars
|
||||
case authlib.TypeUser:
|
||||
stars := &collections.StarsList{}
|
||||
obj, _ := s.Get(ctx, "user-"+user.GetIdentifier(), &v1.GetOptions{})
|
||||
if obj != nil {
|
||||
s, ok := obj.(*collections.Stars)
|
||||
if ok {
|
||||
stars.Items = []collections.Stars{*s}
|
||||
}
|
||||
}
|
||||
return stars, nil
|
||||
|
||||
default:
|
||||
return s.Storage.List(ctx, options)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
package collections
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
||||
v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/runtime"
|
||||
"k8s.io/apiserver/pkg/registry/rest"
|
||||
|
||||
collections "github.com/grafana/grafana/apps/collections/pkg/apis/collections/v1alpha1"
|
||||
"github.com/grafana/grafana/pkg/apimachinery/identity"
|
||||
grafanarest "github.com/grafana/grafana/pkg/apiserver/rest"
|
||||
"github.com/grafana/grafana/pkg/registry/apis/preferences/utils"
|
||||
)
|
||||
|
||||
type starItem struct {
|
||||
group string
|
||||
kind string
|
||||
id string
|
||||
}
|
||||
|
||||
type starsREST struct {
|
||||
store grafanarest.Storage
|
||||
}
|
||||
|
||||
var (
|
||||
_ = rest.Connecter(&starsREST{})
|
||||
_ = rest.StorageMetadata(&starsREST{})
|
||||
)
|
||||
|
||||
func (r *starsREST) New() runtime.Object {
|
||||
return &collections.Stars{}
|
||||
}
|
||||
|
||||
func (r *starsREST) Destroy() {}
|
||||
|
||||
func (r *starsREST) ConnectMethods() []string {
|
||||
return []string{"PUT", "DELETE"}
|
||||
}
|
||||
|
||||
func (r *starsREST) ProducesMIMETypes(verb string) []string {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *starsREST) ProducesObject(verb string) interface{} {
|
||||
return &collections.Stars{}
|
||||
}
|
||||
|
||||
func (r *starsREST) NewConnectOptions() (runtime.Object, bool, string) {
|
||||
return nil, true, "" // true means you can use the trailing path as a variable
|
||||
}
|
||||
|
||||
func (r *starsREST) Connect(ctx context.Context, name string, _ runtime.Object, responder rest.Responder) (http.Handler, error) {
|
||||
user, err := identity.GetRequester(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("must be logged in")
|
||||
}
|
||||
parsed, found := utils.ParseOwnerFromName(name)
|
||||
if !found || parsed.Owner != utils.UserResourceOwner {
|
||||
return nil, fmt.Errorf("only works with user stars")
|
||||
}
|
||||
if user.GetIdentifier() != parsed.Identifier {
|
||||
return nil, fmt.Errorf("must request as the given user")
|
||||
}
|
||||
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||
item, err := itemFromPath(req.URL.Path, fmt.Sprintf("/%s/update", name))
|
||||
if err != nil {
|
||||
responder.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
remove := false
|
||||
switch req.Method {
|
||||
case "DELETE":
|
||||
remove = true
|
||||
case "PUT":
|
||||
remove = false
|
||||
default:
|
||||
responder.Error(apierrors.NewMethodNotSupported(collections.StarsResourceInfo.GroupResource(), req.Method))
|
||||
return
|
||||
}
|
||||
|
||||
current, err := r.store.Get(ctx, name, &v1.GetOptions{})
|
||||
if err != nil {
|
||||
if apierrors.IsNotFound(err) {
|
||||
if remove {
|
||||
responder.Object(http.StatusNoContent, &v1.Status{
|
||||
Code: http.StatusNoContent,
|
||||
})
|
||||
return
|
||||
}
|
||||
current = &collections.Stars{
|
||||
ObjectMeta: v1.ObjectMeta{
|
||||
Name: name,
|
||||
Namespace: user.GetNamespace(),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
obj, ok := current.(*collections.Stars)
|
||||
if !ok {
|
||||
responder.Error(fmt.Errorf("expected stars object"))
|
||||
return
|
||||
}
|
||||
|
||||
if remove {
|
||||
obj.Spec.Remove(item.group, item.kind, item.id)
|
||||
} else {
|
||||
obj.Spec.Add(item.group, item.kind, item.id)
|
||||
}
|
||||
|
||||
if len(obj.Spec.Resource) == 0 {
|
||||
_, _, err = r.store.Delete(ctx, name, rest.ValidateAllObjectFunc, &v1.DeleteOptions{})
|
||||
} else if obj.ResourceVersion == "" {
|
||||
_, err = r.store.Create(ctx, obj, rest.ValidateAllObjectFunc, &v1.CreateOptions{})
|
||||
} else {
|
||||
_, _, err = r.store.Update(ctx, name, rest.DefaultUpdatedObjectInfo(obj), rest.ValidateAllObjectFunc, rest.ValidateAllObjectUpdateFunc, true, &v1.UpdateOptions{})
|
||||
}
|
||||
if err != nil {
|
||||
responder.Error(err)
|
||||
return
|
||||
}
|
||||
responder.Object(http.StatusOK, &v1.Status{Code: http.StatusOK})
|
||||
}), nil
|
||||
}
|
||||
|
||||
func itemFromPath(urlPath, prefix string) (starItem, error) {
|
||||
idx := strings.Index(urlPath, prefix)
|
||||
if idx == -1 {
|
||||
return starItem{}, apierrors.NewBadRequest("invalid request path")
|
||||
}
|
||||
|
||||
path := strings.TrimPrefix(urlPath[idx+len(prefix):], "/")
|
||||
parts := strings.Split(path, "/")
|
||||
if len(parts) != 3 {
|
||||
return starItem{}, apierrors.NewBadRequest("expected {group}/{kind}/{id}")
|
||||
}
|
||||
return starItem{
|
||||
group: parts[0],
|
||||
kind: parts[1],
|
||||
id: parts[2],
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package collections
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestStarsWrite(t *testing.T) {
|
||||
t.Run("path", func(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
url string
|
||||
prefix string
|
||||
item starItem
|
||||
err string
|
||||
}{{
|
||||
name: "normal",
|
||||
url: "http://localhost:3000/apis/collections.grafana.app/v1alpha1/namespaces/default/stars/user-abc/write/dashboard.grafana.app/Dashboard/000000127",
|
||||
prefix: "/user-abc/write",
|
||||
item: starItem{
|
||||
group: "dashboard.grafana.app",
|
||||
kind: "Dashboard",
|
||||
id: "000000127",
|
||||
},
|
||||
}, {
|
||||
name: "prefix not found",
|
||||
url: "http://localhost:3000/apis/collections.grafana.app/v1alpha1/namespaces/default/stars/user-abc/write/dashboard.grafana.app/Dashboard/000000127",
|
||||
prefix: "/something/write",
|
||||
err: "invalid request path",
|
||||
}, {
|
||||
name: "missing three parts",
|
||||
url: "http://localhost:3000/apis/collections.grafana.app/v1alpha1/namespaces/default/stars/user-abc/write/dashboard.grafana.app/000000127",
|
||||
prefix: "/user-abc/write",
|
||||
err: "expected {group}/{kind}/{id}",
|
||||
}}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
item, err := itemFromPath(tt.url, tt.prefix)
|
||||
if tt.err == "" {
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, tt.item, item)
|
||||
} else {
|
||||
require.ErrorContains(t, err, tt.err)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user