K8s: Use client-go to test legacy playlist changes (#77245)

This commit is contained in:
Ryan McKinley 2023-10-27 06:59:49 -07:00 committed by GitHub
parent bf554d121c
commit 9b472b3726
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
3 changed files with 280 additions and 56 deletions

View File

@ -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,
}
}

View File

@ -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)
})
}

View File

@ -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
}