From 9b472b37267e3a58015aa0543b4b40635822771a Mon Sep 17 00:00:00 2001 From: Ryan McKinley Date: Fri, 27 Oct 2023 06:59:49 -0700 Subject: [PATCH] K8s: Use client-go to test legacy playlist changes (#77245) --- pkg/tests/apis/helper.go | 143 ++++++++++++++++--- pkg/tests/apis/playlist/playlist_test.go | 169 +++++++++++++++++++++-- pkg/tests/apis/types.go | 24 ---- 3 files changed, 280 insertions(+), 56 deletions(-) diff --git a/pkg/tests/apis/helper.go b/pkg/tests/apis/helper.go index 86d0b5666cf..52fc57bd574 100644 --- a/pkg/tests/apis/helper.go +++ b/pkg/tests/apis/helper.go @@ -7,12 +7,20 @@ import ( "fmt" "io" "net/http" + "os" "testing" "github.com/stretchr/testify/require" + "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/runtime/schema" - "sigs.k8s.io/yaml" + "k8s.io/apimachinery/pkg/runtime/serializer/yaml" + yamlutil "k8s.io/apimachinery/pkg/util/yaml" + + "k8s.io/client-go/dynamic" + "k8s.io/client-go/rest" "github.com/grafana/grafana/pkg/infra/localcache" "github.com/grafana/grafana/pkg/server" @@ -40,9 +48,6 @@ type K8sTestHelper struct { // // Registered groups groups []metav1.APIGroup - - // Used to build the URL paths - selectedGVR schema.GroupVersionResource } func NewK8sTestHelper(t *testing.T) *K8sTestHelper { @@ -67,7 +72,7 @@ func NewK8sTestHelper(t *testing.T) *K8sTestHelper { c.Org2 = c.createTestUsers(int64(2)) // Read the API groups - rsp := doRequest(c, RequestParams{ + rsp := DoRequest(c, RequestParams{ User: c.Org1.Viewer, Path: "/apis", // Accept: "application/json;g=apidiscovery.k8s.io;v=v2beta1;as=APIGroupDiscoveryList,application/json", @@ -76,6 +81,68 @@ func NewK8sTestHelper(t *testing.T) *K8sTestHelper { return c } +type ResourceClientArgs struct { + User User + Namespace string + GVR schema.GroupVersionResource +} + +type K8sResourceClient struct { + t *testing.T + Args ResourceClientArgs + Resource dynamic.ResourceInterface +} + +// This will set the expected Group/Version/Resource and return the discovery info if found +func (c *K8sTestHelper) GetResourceClient(args ResourceClientArgs) *K8sResourceClient { + c.t.Helper() + + if args.Namespace == "" { + args.Namespace = c.namespacer(args.User.Identity.GetOrgID()) + } + + return &K8sResourceClient{ + t: c.t, + Args: args, + Resource: args.User.Client.Resource(args.GVR).Namespace(args.Namespace), + } +} + +// Cast the error to status error +func (c *K8sTestHelper) AsStatusError(err error) *errors.StatusError { + c.t.Helper() + + if err == nil { + return nil + } + + //nolint:errorlint + statusError, ok := err.(*errors.StatusError) + require.True(c.t, ok) + return statusError +} + +// remove the meta keys that are expected to change each time +func (c *K8sResourceClient) SanitizeJSON(v *unstructured.Unstructured) string { + c.t.Helper() + + copy := v.DeepCopy().Object + meta, ok := copy["metadata"].(map[string]any) + require.True(c.t, ok) + + replaceMeta := []string{"creationTimestamp", "resourceVersion", "uid"} + for _, key := range replaceMeta { + old, ok := meta[key] + require.True(c.t, ok) + require.NotEmpty(c.t, old) + meta[key] = fmt.Sprintf("${%s}", key) + } + + out, err := json.MarshalIndent(copy, "", " ") + require.NoError(c.t, err) + return string(out) +} + type OrgUsers struct { Admin User Editor User @@ -84,6 +151,7 @@ type OrgUsers struct { type User struct { Identity identity.Requester + Client *dynamic.DynamicClient password string } @@ -106,13 +174,6 @@ type K8sResponse[T any] struct { type AnyResourceResponse = K8sResponse[AnyResource] type AnyResourceListResponse = K8sResponse[AnyResourceList] -// This will set the expected Group/Version/Resource and return the discovery info if found -func (c *K8sTestHelper) SetGroupVersionResource(gvr schema.GroupVersionResource) { - c.t.Helper() - - c.selectedGVR = gvr -} - func (c *K8sTestHelper) PostResource(user User, resource string, payload AnyResource) AnyResourceResponse { c.t.Helper() @@ -130,7 +191,7 @@ func (c *K8sTestHelper) PostResource(user User, resource string, payload AnyReso body, err := json.Marshal(payload) require.NoError(c.t, err) - return doRequest(c, RequestParams{ + return DoRequest(c, RequestParams{ Method: http.MethodPost, Path: path, User: user, @@ -147,7 +208,7 @@ func (c *K8sTestHelper) PutResource(user User, resource string, payload AnyResou body, err := json.Marshal(payload) require.NoError(c.t, err) - return doRequest(c, RequestParams{ + return DoRequest(c, RequestParams{ Method: http.MethodPut, Path: path, User: user, @@ -155,20 +216,20 @@ func (c *K8sTestHelper) PutResource(user User, resource string, payload AnyResou }, &AnyResource{}) } -func (c *K8sTestHelper) List(user User, namespace string) AnyResourceListResponse { +func (c *K8sTestHelper) List(user User, namespace string, gvr schema.GroupVersionResource) AnyResourceListResponse { c.t.Helper() - return doRequest(c, RequestParams{ + return DoRequest(c, RequestParams{ User: user, Path: fmt.Sprintf("/apis/%s/%s/namespaces/%s/%s", - c.selectedGVR.Group, - c.selectedGVR.Version, + gvr.Group, + gvr.Version, namespace, - c.selectedGVR.Resource), + gvr.Resource), }, &AnyResourceList{}) } -func doRequest[T any](c *K8sTestHelper, params RequestParams, result *T) K8sResponse[T] { +func DoRequest[T any](c *K8sTestHelper, params RequestParams, result *T) K8sResponse[T] { c.t.Helper() if params.Method == "" { @@ -224,12 +285,38 @@ func doRequest[T any](c *K8sTestHelper, params RequestParams, result *T) K8sResp r.Status = s r.Result = nil } - } else { - _ = yaml.Unmarshal(r.Body, r.Result) } return r } +// Read local JSON or YAML file into a resource +func (c *K8sTestHelper) LoadYAMLOrJSONFile(fpath string) *unstructured.Unstructured { + c.t.Helper() + + //nolint:gosec + raw, err := os.ReadFile(fpath) + require.NoError(c.t, err) + require.NotEmpty(c.t, raw) + return c.LoadYAMLOrJSON(string(raw)) +} + +// Read local JSON or YAML file into a resource +func (c *K8sTestHelper) LoadYAMLOrJSON(body string) *unstructured.Unstructured { + c.t.Helper() + + decoder := yamlutil.NewYAMLOrJSONDecoder(bytes.NewReader([]byte(body)), 100) + var rawObj runtime.RawExtension + err := decoder.Decode(&rawObj) + require.NoError(c.t, err) + + obj, _, err := yaml.NewDecodingSerializer(unstructured.UnstructuredJSONScheme).Decode(rawObj.Raw, nil, nil) + require.NoError(c.t, err) + unstructuredMap, err := runtime.DefaultUnstructuredConverter.ToUnstructured(obj) + require.NoError(c.t, err) + + return &unstructured.Unstructured{Object: unstructuredMap} +} + func (c K8sTestHelper) createTestUsers(orgId int64) OrgUsers { c.t.Helper() @@ -252,6 +339,7 @@ func (c K8sTestHelper) createTestUsers(orgId int64) OrgUsers { supportbundlestest.NewFakeBundleService()) require.NoError(c.t, err) + baseUrl := fmt.Sprintf("http://%s", c.env.Server.HTTPServer.Listener.Addr()) createUser := func(key string, role org.RoleType) User { u, err := userSvc.Create(context.Background(), &user.CreateUserCommand{ DefaultOrgRole: string(role), @@ -272,8 +360,19 @@ func (c K8sTestHelper) createTestUsers(orgId int64) OrgUsers { require.NoError(c.t, err) require.Equal(c.t, orgId, s.OrgID) require.Equal(c.t, role, s.OrgRole) // make sure the role was set properly + + config := &rest.Config{ + Host: baseUrl, + Username: s.Login, + Password: key, + } + + client, err := dynamic.NewForConfig(config) + require.NoError(c.t, err) + return User{ Identity: s, + Client: client, password: key, } } diff --git a/pkg/tests/apis/playlist/playlist_test.go b/pkg/tests/apis/playlist/playlist_test.go index 053f01a634b..9d475d82d5a 100644 --- a/pkg/tests/apis/playlist/playlist_test.go +++ b/pkg/tests/apis/playlist/playlist_test.go @@ -1,12 +1,16 @@ package playlist import ( + "context" + "net/http" + "strings" "testing" "github.com/stretchr/testify/require" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime/schema" + "github.com/grafana/grafana/pkg/services/playlist" "github.com/grafana/grafana/pkg/tests/apis" ) @@ -15,31 +19,176 @@ func TestPlaylist(t *testing.T) { t.Skip("skipping integration test") } helper := apis.NewK8sTestHelper(t) - helper.SetGroupVersionResource( - schema.GroupVersionResource{ - Group: "playlist.grafana.app", - Version: "v0alpha1", - Resource: "playlists", - }) + gvr := schema.GroupVersionResource{ + Group: "playlist.grafana.app", + Version: "v0alpha1", + Resource: "playlists", + } - t.Run("Check List from different org users", func(t *testing.T) { + t.Run("Check direct List permissions from different org users", func(t *testing.T) { // Check view permissions - rsp := helper.List(helper.Org1.Viewer, "default") + rsp := helper.List(helper.Org1.Viewer, "default", gvr) require.Equal(t, 200, rsp.Response.StatusCode) require.NotNil(t, rsp.Result) require.Empty(t, rsp.Result.Items) require.Nil(t, rsp.Status) // Check view permissions - rsp = helper.List(helper.Org2.Viewer, "default") + rsp = helper.List(helper.Org2.Viewer, "default", gvr) require.Equal(t, 403, rsp.Response.StatusCode) // Org2 can not see default namespace require.Nil(t, rsp.Result) require.Equal(t, metav1.StatusReasonForbidden, rsp.Status.Reason) // Check view permissions - rsp = helper.List(helper.Org2.Viewer, "org-22") + rsp = helper.List(helper.Org2.Viewer, "org-22", gvr) require.Equal(t, 403, rsp.Response.StatusCode) // Unknown/not a member require.Nil(t, rsp.Result) require.Equal(t, metav1.StatusReasonForbidden, rsp.Status.Reason) }) + + t.Run("Check k8s client-go List from different org users", func(t *testing.T) { + // Check Org1 Viewer + client := helper.GetResourceClient(apis.ResourceClientArgs{ + User: helper.Org1.Viewer, + Namespace: "", // << fills in the value org1 is allowed to see! + GVR: gvr, + }) + rsp, err := client.Resource.List(context.Background(), metav1.ListOptions{}) + require.NoError(t, err) + require.Empty(t, rsp.Items) + + // Check org2 viewer can not see org1 (default namespace) + client = helper.GetResourceClient(apis.ResourceClientArgs{ + User: helper.Org2.Viewer, + Namespace: "default", // actually org1 + GVR: gvr, + }) + rsp, err = client.Resource.List(context.Background(), metav1.ListOptions{}) + statusError := helper.AsStatusError(err) + require.Nil(t, rsp) + require.Equal(t, metav1.StatusReasonForbidden, statusError.Status().Reason) + + // Check invalid namespace + client = helper.GetResourceClient(apis.ResourceClientArgs{ + User: helper.Org2.Viewer, + Namespace: "org-22", // org 22 does not exist + GVR: gvr, + }) + rsp, err = client.Resource.List(context.Background(), metav1.ListOptions{}) + statusError = helper.AsStatusError(err) + require.Nil(t, rsp) + require.Equal(t, metav1.StatusReasonForbidden, statusError.Status().Reason) + }) + + t.Run("Check playlist CRUD in legacy API appears in k8s apis", func(t *testing.T) { + client := helper.GetResourceClient(apis.ResourceClientArgs{ + User: helper.Org1.Editor, + GVR: gvr, + }) + + // This includes the raw dashboard values that are currently sent (but should not be and are ignored) + legacyPayload := `{ + "name": "Test", + "interval": "20s", + "items": [ + { + "type": "dashboard_by_uid", + "value": "xCmMwXdVz", + "dashboards": [ + { + "name": "The dashboard", + "kind": "dashboard", + "uid": "xCmMwXdVz", + "url": "/d/xCmMwXdVz/barchart-label-rotation-and-skipping", + "tags": ["barchart", "gdev", "graph-ng", "panel-tests"], + "location": "d1de6240-fd2e-4e13-99b6-f9d0c6b0550d" + } + ] + }, + { + "type": "dashboard_by_tag", + "value": "graph-ng", + "dashboards": [ "..." ] + } + ], + "uid": "" + }` + legacyCreate := apis.DoRequest(helper, apis.RequestParams{ + User: client.Args.User, + Method: http.MethodPost, + Path: "/api/playlists", + Body: []byte(legacyPayload), + }, &playlist.Playlist{}) + require.NotNil(t, legacyCreate.Result) + uid := legacyCreate.Result.UID + require.NotEmpty(t, uid) + + expectedResult := `{ + "apiVersion": "playlist.grafana.app/v0alpha1", + "kind": "Playlist", + "metadata": { + "creationTimestamp": "${creationTimestamp}", + "name": "` + uid + `", + "namespace": "default", + "resourceVersion": "${resourceVersion}", + "uid": "${uid}" + }, + "spec": { + "title": "Test", + "interval": "20s", + "items": [ + { + "type": "dashboard_by_uid", + "value": "xCmMwXdVz" + }, + { + "type": "dashboard_by_tag", + "value": "graph-ng" + } + ] + } + }` + + // List includes the expected result + k8sList, err := client.Resource.List(context.Background(), metav1.ListOptions{}) + require.NoError(t, err) + require.Equal(t, 1, len(k8sList.Items)) + require.JSONEq(t, expectedResult, client.SanitizeJSON(&k8sList.Items[0])) + + // Get should return the same result + found, err := client.Resource.Get(context.Background(), uid, metav1.GetOptions{}) + require.NoError(t, err) + require.JSONEq(t, expectedResult, client.SanitizeJSON(found)) + + // Now modify the interval + updatedInterval := `"interval": "10m"` + legacyPayload = strings.Replace(legacyPayload, `"interval": "20s"`, updatedInterval, 1) + expectedResult = strings.Replace(expectedResult, `"interval": "20s"`, updatedInterval, 1) + dtoResponse := apis.DoRequest(helper, apis.RequestParams{ + User: client.Args.User, + Method: http.MethodPut, + Path: "/api/playlists/" + uid, + Body: []byte(legacyPayload), + }, &playlist.PlaylistDTO{}) + require.Equal(t, uid, dtoResponse.Result.Uid) + require.Equal(t, "10m", dtoResponse.Result.Interval) + + // Make sure the changed interval is now returned from k8s + found, err = client.Resource.Get(context.Background(), uid, metav1.GetOptions{}) + require.NoError(t, err) + require.JSONEq(t, expectedResult, client.SanitizeJSON(found)) + + // Delete does not return anything + _ = apis.DoRequest(helper, apis.RequestParams{ + User: client.Args.User, + Method: http.MethodDelete, + Path: "/api/playlists/" + uid, + Body: []byte(legacyPayload), + }, &playlist.PlaylistDTO{}) // response is empty + + found, err = client.Resource.Get(context.Background(), uid, metav1.GetOptions{}) + statusError := helper.AsStatusError(err) + require.Nil(t, found) + require.Equal(t, metav1.StatusReasonNotFound, statusError.Status().Reason) + }) } diff --git a/pkg/tests/apis/types.go b/pkg/tests/apis/types.go index b405de90cd7..5c8a24563c2 100644 --- a/pkg/tests/apis/types.go +++ b/pkg/tests/apis/types.go @@ -1,12 +1,7 @@ package apis import ( - "encoding/json" - "os" - - "github.com/stretchr/testify/require" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "sigs.k8s.io/yaml" ) type AnyResource struct { @@ -24,22 +19,3 @@ type AnyResourceList struct { Items []map[string]any `json:"items,omitempty"` } - -// Read local JSON or YAML file into a resource -func (c *K8sTestHelper) LoadAnyResource(fpath string) AnyResource { - c.t.Helper() - - //nolint:gosec - raw, err := os.ReadFile(fpath) - require.NoError(c.t, err) - require.NotEmpty(c.t, raw) - - res := &AnyResource{} - if json.Valid(raw) { - err = json.Unmarshal(raw, res) - } else { - err = yaml.Unmarshal(raw, res) - } - require.NoError(c.t, err) - return *res -}