Stars: implement full CRUD operations via legacy service (#110489)

This commit is contained in:
Ryan McKinley
2025-09-04 14:49:49 -05:00
committed by GitHub
parent 1dadf2cad9
commit 4723d2d8de
9 changed files with 543 additions and 49 deletions
+188 -30
View File
@@ -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)),
+84 -6
View File
@@ -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
}
+101
View File
@@ -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
}
+2 -2
View File
@@ -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,
},
+2 -2
View File
@@ -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)
+4 -2
View File
@@ -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
+155
View File
@@ -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
}