Stars: implement full CRUD operations via legacy service (#110489)
This commit is contained in:
@@ -3,6 +3,7 @@ package legacy
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -11,61 +12,75 @@ import (
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/runtime"
|
||||
"k8s.io/apiserver/pkg/registry/rest"
|
||||
"k8s.io/utils/ptr"
|
||||
|
||||
authlib "github.com/grafana/authlib/types"
|
||||
dashboardsV1 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v1beta1"
|
||||
preferences "github.com/grafana/grafana/apps/preferences/pkg/apis/preferences/v1alpha1"
|
||||
"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 = (*starsStorage)(nil)
|
||||
_ rest.SingularNameProvider = (*starsStorage)(nil)
|
||||
_ rest.Getter = (*starsStorage)(nil)
|
||||
_ rest.Lister = (*starsStorage)(nil)
|
||||
_ rest.Storage = (*starsStorage)(nil)
|
||||
// _ rest.Creater = (*starsStorage)(nil)
|
||||
// _ rest.Updater = (*starsStorage)(nil)
|
||||
// _ rest.GracefulDeleter = (*starsStorage)(nil)
|
||||
_ 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 NewStarsStorage(namespacer request.NamespaceMapper, sql *LegacySQL) *starsStorage {
|
||||
return &starsStorage{
|
||||
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: preferences.StarsResourceInfo.TableConverter(),
|
||||
}
|
||||
}
|
||||
|
||||
type starsStorage struct {
|
||||
type DashboardStarsStorage struct {
|
||||
namespacer request.NamespaceMapper
|
||||
tableConverter rest.TableConvertor
|
||||
sql *LegacySQL
|
||||
stars star.Service
|
||||
users user.Service
|
||||
}
|
||||
|
||||
func (s *starsStorage) New() runtime.Object {
|
||||
func (s *DashboardStarsStorage) New() runtime.Object {
|
||||
return preferences.StarsKind().ZeroValue()
|
||||
}
|
||||
|
||||
func (s *starsStorage) Destroy() {}
|
||||
func (s *DashboardStarsStorage) Destroy() {}
|
||||
|
||||
func (s *starsStorage) NamespaceScoped() bool {
|
||||
func (s *DashboardStarsStorage) NamespaceScoped() bool {
|
||||
return true // namespace == org
|
||||
}
|
||||
|
||||
func (s *starsStorage) GetSingularName() string {
|
||||
func (s *DashboardStarsStorage) GetSingularName() string {
|
||||
return strings.ToLower(preferences.StarsKind().Kind())
|
||||
}
|
||||
|
||||
func (s *starsStorage) NewList() runtime.Object {
|
||||
func (s *DashboardStarsStorage) NewList() runtime.Object {
|
||||
return preferences.StarsKind().ZeroListValue()
|
||||
}
|
||||
|
||||
func (s *starsStorage) ConvertToTable(ctx context.Context, object runtime.Object, tableOptions runtime.Object) (*metav1.Table, error) {
|
||||
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 *starsStorage) List(ctx context.Context, options *internalversion.ListOptions) (runtime.Object, error) {
|
||||
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
|
||||
@@ -89,32 +104,175 @@ func (s *starsStorage) List(ctx context.Context, options *internalversion.ListOp
|
||||
return list, nil
|
||||
}
|
||||
|
||||
func (s *starsStorage) Get(ctx context.Context, name string, options *metav1.GetOptions) (runtime.Object, error) {
|
||||
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
|
||||
}
|
||||
|
||||
ut, uid, err := authlib.ParseTypeID(name)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid name %w", err)
|
||||
}
|
||||
if ut != authlib.TypeUser {
|
||||
return nil, fmt.Errorf("expecting name with prefix: %s", authlib.TypeUser)
|
||||
}
|
||||
|
||||
found, _, err := s.sql.GetStars(ctx, info.OrgID, uid)
|
||||
found, _, err := s.sql.GetStars(ctx, ns.OrgID, owner.Name)
|
||||
if err != nil || len(found) == 0 {
|
||||
return nil, err
|
||||
}
|
||||
obj := asStarsResource(info.Value, &found[0])
|
||||
obj := asStarsResource(ns.Value, &found[0])
|
||||
return &obj, nil
|
||||
}
|
||||
|
||||
func (s *DashboardStarsStorage) StarDashboard(ctx context.Context, name string, uid string) (runtime.Object, error) {
|
||||
return nil, fmt.Errorf("TODO")
|
||||
}
|
||||
|
||||
func (s *DashboardStarsStorage) UnstarDashboard(ctx context.Context, name string, uid string) (runtime.Object, error) {
|
||||
return nil, fmt.Errorf("TODO")
|
||||
}
|
||||
|
||||
func getDashboardStars(stars *preferences.Stars) []string {
|
||||
if stars == nil || len(stars.Spec.Resource) == 0 {
|
||||
return []string{}
|
||||
}
|
||||
for _, r := range stars.Spec.Resource {
|
||||
if r.Group == "dashboard.grafana.app" && r.Kind == "Dashboard" {
|
||||
return r.Names
|
||||
}
|
||||
}
|
||||
return []string{}
|
||||
}
|
||||
|
||||
// Create implements rest.Creater.
|
||||
func (s *DashboardStarsStorage) write(ctx context.Context, obj *preferences.Stars, old *preferences.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.Name,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if user.OrgID != ns.OrgID {
|
||||
return nil, fmt.Errorf("namespace mismatch")
|
||||
}
|
||||
|
||||
stars := getDashboardStars(obj)
|
||||
if len(stars) == 0 {
|
||||
err = s.stars.DeleteByUser(ctx, user.ID)
|
||||
return &preferences.Stars{ObjectMeta: metav1.ObjectMeta{
|
||||
Name: obj.Name,
|
||||
Namespace: obj.Namespace,
|
||||
DeletionTimestamp: ptr.To(metav1.Now()),
|
||||
}}, err
|
||||
}
|
||||
|
||||
changed := false
|
||||
now := time.Now()
|
||||
randID := now.UnixNano() + rand.Int63n(5000)
|
||||
previous := make(map[string]bool)
|
||||
for _, v := range getDashboardStars(obj) {
|
||||
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.(*preferences.Stars)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("expected stars object")
|
||||
}
|
||||
|
||||
return s.write(ctx, stars, nil)
|
||||
}
|
||||
|
||||
// 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.(*preferences.Stars)
|
||||
if !ok {
|
||||
return nil, false, fmt.Errorf("expected stars object")
|
||||
}
|
||||
|
||||
obj, err = s.write(ctx, stars, old.(*preferences.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, &preferences.Stars{ObjectMeta: metav1.ObjectMeta{Name: name}}, nil)
|
||||
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) preferences.Stars {
|
||||
return preferences.Stars{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: fmt.Sprintf("user:%s", v.UserUID),
|
||||
Name: fmt.Sprintf("user-%s", v.UserUID),
|
||||
Namespace: ns,
|
||||
ResourceVersion: strconv.FormatInt(v.Last, 10),
|
||||
CreationTimestamp: metav1.NewTime(time.UnixMilli(v.First)),
|
||||
|
||||
@@ -1,21 +1,27 @@
|
||||
package preferences
|
||||
|
||||
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/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"
|
||||
|
||||
preferences "github.com/grafana/grafana/apps/preferences/pkg/apis/preferences/v1alpha1"
|
||||
grafanaregistry "github.com/grafana/grafana/pkg/apiserver/registry/generic"
|
||||
"github.com/grafana/grafana/pkg/infra/db"
|
||||
"github.com/grafana/grafana/pkg/registry/apis/preferences/legacy"
|
||||
"github.com/grafana/grafana/pkg/services/apiserver/builder"
|
||||
"github.com/grafana/grafana/pkg/services/apiserver/endpoints/request"
|
||||
"github.com/grafana/grafana/pkg/services/featuremgmt"
|
||||
pref "github.com/grafana/grafana/pkg/services/preference"
|
||||
"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"
|
||||
)
|
||||
@@ -26,7 +32,9 @@ type APIBuilder struct {
|
||||
namespacer request.NamespaceMapper
|
||||
sql *legacy.LegacySQL
|
||||
|
||||
stars star.Service
|
||||
prefs pref.Service
|
||||
users user.Service
|
||||
calculator *calculator // joins all preferences
|
||||
}
|
||||
|
||||
@@ -35,6 +43,8 @@ func RegisterAPIService(
|
||||
features featuremgmt.FeatureToggles,
|
||||
db db.DB,
|
||||
prefs pref.Service,
|
||||
stars star.Service,
|
||||
users user.Service,
|
||||
apiregistration builder.APIRegistrar,
|
||||
) *APIBuilder {
|
||||
// Requires development settings and clearly experimental
|
||||
@@ -45,6 +55,8 @@ func RegisterAPIService(
|
||||
sql := legacy.NewLegacySQL(legacysql.NewDatabaseProvider(db))
|
||||
builder := &APIBuilder{
|
||||
prefs: prefs, // for writing
|
||||
stars: stars, // for writing
|
||||
users: users, // for writing
|
||||
namespacer: request.GetNamespaceMapper(cfg),
|
||||
sql: sql,
|
||||
calculator: newCalculator(cfg, sql),
|
||||
@@ -76,15 +88,26 @@ func (b *APIBuilder) InstallSchema(scheme *runtime.Scheme) error {
|
||||
func (b *APIBuilder) UpdateAPIGroupInfo(apiGroupInfo *genericapiserver.APIGroupInfo, opts builder.APIGroupOptions) error {
|
||||
storage := map[string]rest.Storage{}
|
||||
|
||||
// Configure Stars Dual writer
|
||||
stars := preferences.StarsResourceInfo
|
||||
storage[stars.StoragePath()] = legacy.NewStarsStorage(b.namespacer, b.sql)
|
||||
unified, err := grafanaregistry.NewRegistryStore(opts.Scheme, stars, opts.OptsGetter)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
storage[stars.StoragePath()] = unified
|
||||
if b.stars != nil {
|
||||
legacy := legacy.NewDashboardStarsStorage(b.stars, b.users, b.namespacer, b.sql)
|
||||
storage[stars.StoragePath()], err = opts.DualWriteBuilder(stars.GroupResource(), legacy, unified)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
storage[stars.StoragePath("write")] = &starsREST{
|
||||
store: legacy, // TODO, only supports legacy right now
|
||||
}
|
||||
}
|
||||
|
||||
// Configure Preferences
|
||||
prefs := preferences.PreferencesResourceInfo
|
||||
// Unified storage
|
||||
// store, err := grafanaregistry.NewRegistryStore(opts.Scheme, resourceInfo, opts.OptsGetter)
|
||||
// if err != nil {
|
||||
// return err
|
||||
// }
|
||||
storage[prefs.StoragePath()] = legacy.NewPreferencesStorage(b.namespacer, b.sql)
|
||||
|
||||
apiGroupInfo.VersionedResourcesStorageMap[preferences.APIVersion] = storage
|
||||
@@ -99,3 +122,58 @@ func (b *APIBuilder) GetAPIRoutes(gv schema.GroupVersion) *builder.APIRoutes {
|
||||
defs := b.GetOpenAPIDefinitions()(func(path string) spec.Ref { return spec.Ref{} })
|
||||
return b.calculator.GetAPIRoutes(defs)
|
||||
}
|
||||
|
||||
func (b *APIBuilder) PostProcessOpenAPI(oas *spec3.OpenAPI) (*spec3.OpenAPI, error) {
|
||||
oas.Info.Description = "Grafana preferences"
|
||||
|
||||
root := "/apis/" + b.GetGroupVersion().String() + "/"
|
||||
writeKey := root + "namespaces/{namespace}/stars/{name}/write"
|
||||
delete(oas.Paths.Paths, writeKey)
|
||||
|
||||
// Add the group/kind/id properties to the path
|
||||
stars, ok := oas.Paths.Paths[writeKey+"/{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, writeKey+"/{path}")
|
||||
oas.Paths.Paths[writeKey+"/{group}/{kind}/{id}"] = stars
|
||||
|
||||
return oas, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
package preferences
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
||||
"k8s.io/apimachinery/pkg/runtime"
|
||||
"k8s.io/apiserver/pkg/registry/rest"
|
||||
|
||||
preferences "github.com/grafana/grafana/apps/preferences/pkg/apis/preferences/v1alpha1"
|
||||
"github.com/grafana/grafana/pkg/registry/apis/preferences/legacy"
|
||||
)
|
||||
|
||||
type starItem struct {
|
||||
group string
|
||||
kind string
|
||||
id string
|
||||
}
|
||||
|
||||
type starsREST struct {
|
||||
store *legacy.DashboardStarsStorage
|
||||
}
|
||||
|
||||
var (
|
||||
_ = rest.Connecter(&starsREST{})
|
||||
_ = rest.StorageMetadata(&starsREST{})
|
||||
)
|
||||
|
||||
func (r *starsREST) New() runtime.Object {
|
||||
return &preferences.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 &preferences.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) {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||
item, err := itemFromPath(req.URL.Path, fmt.Sprintf("/%s/write", name))
|
||||
if err != nil {
|
||||
responder.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
if item.group != "dashboard.grafana.app" || item.kind != "Dashboard" {
|
||||
responder.Error(fmt.Errorf("only dashboards are supported right now"))
|
||||
return
|
||||
}
|
||||
|
||||
var obj runtime.Object
|
||||
switch req.Method {
|
||||
case "DELETE":
|
||||
obj, err = r.store.UnstarDashboard(ctx, name, item.id)
|
||||
case "PUT":
|
||||
obj, err = r.store.StarDashboard(ctx, name, item.id)
|
||||
default:
|
||||
err = fmt.Errorf("unsupported method")
|
||||
}
|
||||
if err != nil {
|
||||
responder.Error(err)
|
||||
return
|
||||
}
|
||||
responder.Object(200, obj)
|
||||
}), 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
|
||||
}
|
||||
@@ -25,11 +25,11 @@ func (o OwnerReference) AsName() string {
|
||||
if o.Name == "" || o.Owner == NamespaceResourceOwner {
|
||||
return string(o.Owner)
|
||||
}
|
||||
return string(o.Owner) + ":" + o.Name
|
||||
return string(o.Owner) + "-" + o.Name
|
||||
}
|
||||
|
||||
func ParseOwnerFromName(name string) (OwnerReference, bool) {
|
||||
before, after, found := strings.Cut(name, ":")
|
||||
before, after, found := strings.Cut(name, "-")
|
||||
if found && len(after) > 0 {
|
||||
switch before {
|
||||
case "user":
|
||||
|
||||
@@ -17,31 +17,31 @@ func TestLegacyAuthorizer(t *testing.T) {
|
||||
}{
|
||||
{
|
||||
name: "invalid",
|
||||
input: "xxx:yyy",
|
||||
input: "xxx-yyy",
|
||||
output: utils.OwnerReference{},
|
||||
found: false,
|
||||
},
|
||||
{
|
||||
name: "with user",
|
||||
input: "user:a",
|
||||
input: "user-a",
|
||||
output: utils.OwnerReference{Owner: utils.UserResourceOwner, Name: "a"},
|
||||
found: true,
|
||||
},
|
||||
{
|
||||
name: "missing user",
|
||||
input: "user:",
|
||||
input: "user-",
|
||||
output: utils.OwnerReference{},
|
||||
found: false,
|
||||
},
|
||||
{
|
||||
name: "with team",
|
||||
input: "team:b",
|
||||
input: "team-b",
|
||||
output: utils.OwnerReference{Owner: utils.TeamResourceOwner, Name: "b"},
|
||||
found: true,
|
||||
},
|
||||
{
|
||||
name: "missing team",
|
||||
input: "team:",
|
||||
input: "team-",
|
||||
output: utils.OwnerReference{},
|
||||
found: false,
|
||||
},
|
||||
|
||||
@@ -809,7 +809,7 @@ func Initialize(ctx context.Context, cfg *setting.Cfg, opts Options, apiOpts api
|
||||
return nil, err
|
||||
}
|
||||
userStorageAPIBuilder := userstorage.RegisterAPIService(featureToggles, apiserverService, registerer)
|
||||
apiBuilder := preferences.RegisterAPIService(cfg, featureToggles, sqlStore, prefService, apiserverService)
|
||||
apiBuilder := preferences.RegisterAPIService(cfg, featureToggles, sqlStore, prefService, starService, userService, apiserverService)
|
||||
legacyMigrator := legacy.ProvideLegacyMigrator(sqlStore, provisioningServiceImpl, libraryPanelService, dashboardPermissionsService, accessControl, featureToggles)
|
||||
webhookExtraBuilder := webhooks.ProvideWebhooks(cfg, renderingService, resourceClient, eventualRestConfigProvider)
|
||||
v3 := extras.ProvideProvisioningOSSExtras(webhookExtraBuilder)
|
||||
@@ -1394,7 +1394,7 @@ func InitializeForTest(ctx context.Context, t sqlutil.ITestDB, testingT interfac
|
||||
return nil, err
|
||||
}
|
||||
userStorageAPIBuilder := userstorage.RegisterAPIService(featureToggles, apiserverService, registerer)
|
||||
apiBuilder := preferences.RegisterAPIService(cfg, featureToggles, sqlStore, prefService, apiserverService)
|
||||
apiBuilder := preferences.RegisterAPIService(cfg, featureToggles, sqlStore, prefService, starService, userService, apiserverService)
|
||||
legacyMigrator := legacy.ProvideLegacyMigrator(sqlStore, provisioningServiceImpl, libraryPanelService, dashboardPermissionsService, accessControl, featureToggles)
|
||||
webhookExtraBuilder := webhooks.ProvideWebhooks(cfg, renderingService, resourceClient, eventualRestConfigProvider)
|
||||
v3 := extras.ProvideProvisioningOSSExtras(webhookExtraBuilder)
|
||||
|
||||
@@ -787,7 +787,7 @@ func VerifyOpenAPISnapshots(t *testing.T, dir string, gv schema.GroupVersion, h
|
||||
return // skip invalid groups
|
||||
}
|
||||
path := fmt.Sprintf("/openapi/v3/apis/%s/%s", gv.Group, gv.Version)
|
||||
t.Run(path, func(t *testing.T) {
|
||||
t.Run(path[1:], func(t *testing.T) {
|
||||
rsp := DoRequest(h, RequestParams{
|
||||
Method: http.MethodGet,
|
||||
Path: path,
|
||||
@@ -795,7 +795,9 @@ func VerifyOpenAPISnapshots(t *testing.T, dir string, gv schema.GroupVersion, h
|
||||
}, &AnyResource{})
|
||||
|
||||
require.NotNil(t, rsp.Response)
|
||||
require.Equal(t, 200, rsp.Response.StatusCode, path)
|
||||
if rsp.Response.StatusCode != 200 {
|
||||
require.Failf(t, "Not OK", "Code[%d] %s", rsp.Response.StatusCode, string(rsp.Body))
|
||||
}
|
||||
|
||||
var prettyJSON bytes.Buffer
|
||||
err := json.Indent(&prettyJSON, rsp.Body, "", " ")
|
||||
|
||||
@@ -96,8 +96,8 @@ func TestIntegrationPreferences(t *testing.T) {
|
||||
}
|
||||
require.Equal(t, []string{
|
||||
"namespace",
|
||||
fmt.Sprintf("team:%s", helper.Org1.Staff.UID),
|
||||
clientAdmin.Args.User.Identity.GetUID(),
|
||||
fmt.Sprintf("team-%s", helper.Org1.Staff.UID),
|
||||
fmt.Sprintf("user-%s", clientAdmin.Args.User.Identity.GetIdentifier()),
|
||||
}, names)
|
||||
|
||||
// The viewer should only have namespace (eg org level) permissions
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
package preferences
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
||||
|
||||
dashboardV1 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v1beta1"
|
||||
preferences "github.com/grafana/grafana/apps/preferences/pkg/apis/preferences/v1alpha1"
|
||||
"github.com/grafana/grafana/pkg/services/featuremgmt"
|
||||
"github.com/grafana/grafana/pkg/tests/apis"
|
||||
"github.com/grafana/grafana/pkg/tests/testinfra"
|
||||
)
|
||||
|
||||
func TestIntegrationStars(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.FlagGrafanaAPIServerWithExperimentalAPIs,
|
||||
},
|
||||
})
|
||||
|
||||
t.Run("legacy dashboard stars", func(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
starsClient := helper.GetResourceClient(apis.ResourceClientArgs{
|
||||
User: helper.Org1.Admin,
|
||||
GVR: preferences.StarsResourceInfo.GroupVersionResource(),
|
||||
})
|
||||
dashboardClient := helper.GetResourceClient(apis.ResourceClientArgs{
|
||||
User: helper.Org1.Admin,
|
||||
GVR: dashboardV1.DashboardResourceInfo.GroupVersionResource(),
|
||||
})
|
||||
|
||||
// Create 5 dashboards
|
||||
for i := range 5 {
|
||||
_, err := dashboardClient.Resource.Create(context.Background(), &unstructured.Unstructured{
|
||||
Object: map[string]any{
|
||||
"apiVersion": dashboardV1.DashboardResourceInfo.GroupVersion().String(),
|
||||
"kind": "Dashboard",
|
||||
"metadata": map[string]any{
|
||||
"name": fmt.Sprintf("test-%d", i),
|
||||
},
|
||||
"spec": map[string]any{
|
||||
"title": fmt.Sprintf("test %d", i),
|
||||
"schemaVersion": 41, // not really!
|
||||
"panels": []any{},
|
||||
},
|
||||
},
|
||||
}, metav1.CreateOptions{})
|
||||
require.NoError(t, err)
|
||||
}
|
||||
found, err := dashboardClient.Resource.List(context.Background(), metav1.ListOptions{})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, found.Items, 5, "should be 5 dashboards")
|
||||
|
||||
// List is empty when we start
|
||||
rsp, err := starsClient.Resource.List(ctx, metav1.ListOptions{})
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, rsp.Items, "no stars saved yet")
|
||||
|
||||
raw := make(map[string]any)
|
||||
legacyResponse := apis.DoRequest(helper, apis.RequestParams{
|
||||
User: starsClient.Args.User,
|
||||
Method: http.MethodPost,
|
||||
Path: "/api/user/stars/dashboard/uid/test-2",
|
||||
}, &raw)
|
||||
require.Equal(t, http.StatusOK, legacyResponse.Response.StatusCode, "add dashboard star")
|
||||
legacyResponse = apis.DoRequest(helper, apis.RequestParams{
|
||||
User: starsClient.Args.User,
|
||||
Method: http.MethodPost,
|
||||
Path: "/api/user/stars/dashboard/uid/test-3",
|
||||
}, &raw)
|
||||
require.Equal(t, http.StatusOK, legacyResponse.Response.StatusCode, "add dashboard star")
|
||||
|
||||
// List values and compare results
|
||||
rsp, err = starsClient.Resource.List(ctx, metav1.ListOptions{})
|
||||
require.NoError(t, err)
|
||||
stars := typed(t, rsp, &preferences.StarsList{})
|
||||
|
||||
require.Len(t, stars.Items, 1, "user stars should exist")
|
||||
require.Equal(t, "user-"+starsClient.Args.User.Identity.GetIdentifier(),
|
||||
stars.Items[0].GetName(), "star resource for user")
|
||||
resources := stars.Items[0].Spec.Resource
|
||||
require.Len(t, resources, 1)
|
||||
require.Equal(t, "dashboard.grafana.app", resources[0].Group)
|
||||
require.Equal(t, "Dashboard", resources[0].Kind)
|
||||
require.ElementsMatch(t, []string{"test-2", "test-3"}, resources[0].Names)
|
||||
|
||||
// Remove one star
|
||||
legacyResponse = apis.DoRequest(helper, apis.RequestParams{
|
||||
User: starsClient.Args.User,
|
||||
Method: http.MethodDelete,
|
||||
Path: "/api/user/stars/dashboard/uid/test-3",
|
||||
}, &raw)
|
||||
require.Equal(t, http.StatusOK, legacyResponse.Response.StatusCode, "removed dashboard star")
|
||||
|
||||
rspObj, err := starsClient.Resource.Get(ctx, "user-"+starsClient.Args.User.Identity.GetIdentifier(), metav1.GetOptions{})
|
||||
require.NoError(t, err)
|
||||
|
||||
after := typed(t, rspObj, &preferences.Stars{})
|
||||
resources = after.Spec.Resource
|
||||
require.Len(t, resources, 1)
|
||||
require.Equal(t, "dashboard.grafana.app", resources[0].Group)
|
||||
require.Equal(t, "Dashboard", resources[0].Kind)
|
||||
require.Equal(t, []string{"test-2"}, resources[0].Names)
|
||||
|
||||
// Change stars via k8s update
|
||||
rspObj, err = starsClient.Resource.Update(ctx, &unstructured.Unstructured{
|
||||
Object: map[string]interface{}{
|
||||
"metadata": map[string]any{
|
||||
"name": "user-" + starsClient.Args.User.Identity.GetIdentifier(),
|
||||
"namespace": "default",
|
||||
},
|
||||
"spec": map[string]any{
|
||||
"resource": []map[string]any{
|
||||
{
|
||||
"group": "dashboard.grafana.app",
|
||||
"kind": "Dashboard",
|
||||
"names": []string{"test-2", "aaa", "bbb"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}, metav1.UpdateOptions{})
|
||||
require.NoError(t, err)
|
||||
|
||||
after = typed(t, rspObj, &preferences.Stars{})
|
||||
resources = after.Spec.Resource
|
||||
require.Len(t, resources, 1)
|
||||
require.Equal(t, "dashboard.grafana.app", resources[0].Group)
|
||||
require.Equal(t, "Dashboard", resources[0].Kind)
|
||||
require.ElementsMatch(t,
|
||||
[]string{"test-2", "aaa", "bbb"}, // NOTE 2 stays, 3 removed, added aaa+bbb
|
||||
resources[0].Names)
|
||||
})
|
||||
}
|
||||
|
||||
func typed[T any](t *testing.T, obj any, out T) T {
|
||||
jj, err := json.Marshal(obj)
|
||||
require.NoError(t, err)
|
||||
err = json.Unmarshal(jj, out)
|
||||
require.NoError(t, err)
|
||||
return out
|
||||
}
|
||||
Reference in New Issue
Block a user