From 4723d2d8deb7da20937bff8f2bb5653dc34419f4 Mon Sep 17 00:00:00 2001 From: Ryan McKinley Date: Thu, 4 Sep 2025 22:49:49 +0300 Subject: [PATCH] Stars: implement full CRUD operations via legacy service (#110489) --- pkg/registry/apis/preferences/legacy/stars.go | 218 +++++++++++++++--- pkg/registry/apis/preferences/register.go | 90 +++++++- pkg/registry/apis/preferences/stars.go | 101 ++++++++ pkg/registry/apis/preferences/utils/names.go | 4 +- .../apis/preferences/utils/names_test.go | 10 +- pkg/server/wire_gen.go | 4 +- pkg/tests/apis/helper.go | 6 +- .../apis/preferences/preferences_test.go | 4 +- pkg/tests/apis/preferences/stars_test.go | 155 +++++++++++++ 9 files changed, 543 insertions(+), 49 deletions(-) create mode 100644 pkg/registry/apis/preferences/stars.go create mode 100644 pkg/tests/apis/preferences/stars_test.go diff --git a/pkg/registry/apis/preferences/legacy/stars.go b/pkg/registry/apis/preferences/legacy/stars.go index 6171c7751a6..24b7290f2c3 100644 --- a/pkg/registry/apis/preferences/legacy/stars.go +++ b/pkg/registry/apis/preferences/legacy/stars.go @@ -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)), diff --git a/pkg/registry/apis/preferences/register.go b/pkg/registry/apis/preferences/register.go index 8fc4d09f21e..704d314cd26 100644 --- a/pkg/registry/apis/preferences/register.go +++ b/pkg/registry/apis/preferences/register.go @@ -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 +} diff --git a/pkg/registry/apis/preferences/stars.go b/pkg/registry/apis/preferences/stars.go new file mode 100644 index 00000000000..61d9cbd7f81 --- /dev/null +++ b/pkg/registry/apis/preferences/stars.go @@ -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 +} diff --git a/pkg/registry/apis/preferences/utils/names.go b/pkg/registry/apis/preferences/utils/names.go index 1d6fd7f3d9f..67da127d786 100644 --- a/pkg/registry/apis/preferences/utils/names.go +++ b/pkg/registry/apis/preferences/utils/names.go @@ -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": diff --git a/pkg/registry/apis/preferences/utils/names_test.go b/pkg/registry/apis/preferences/utils/names_test.go index 720fe8867d1..e1e5fc77abd 100644 --- a/pkg/registry/apis/preferences/utils/names_test.go +++ b/pkg/registry/apis/preferences/utils/names_test.go @@ -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, }, diff --git a/pkg/server/wire_gen.go b/pkg/server/wire_gen.go index 7aea5e3894d..df214129e03 100644 --- a/pkg/server/wire_gen.go +++ b/pkg/server/wire_gen.go @@ -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) diff --git a/pkg/tests/apis/helper.go b/pkg/tests/apis/helper.go index 6652134be94..650a614a6a3 100644 --- a/pkg/tests/apis/helper.go +++ b/pkg/tests/apis/helper.go @@ -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, "", " ") diff --git a/pkg/tests/apis/preferences/preferences_test.go b/pkg/tests/apis/preferences/preferences_test.go index 21321070408..a836565595c 100644 --- a/pkg/tests/apis/preferences/preferences_test.go +++ b/pkg/tests/apis/preferences/preferences_test.go @@ -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 diff --git a/pkg/tests/apis/preferences/stars_test.go b/pkg/tests/apis/preferences/stars_test.go new file mode 100644 index 00000000000..e473d44e19d --- /dev/null +++ b/pkg/tests/apis/preferences/stars_test.go @@ -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 +}