mirror of
https://github.com/rancher/steve.git
synced 2025-07-01 01:02:08 +00:00
* Add aggregation layer support * prefer testing.Cleanup * add sni certs to server opts * test cleanup * append snicerts instead of overwriting --------- Co-authored-by: Tom Lebreux <tom.lebreux@suse.com> Co-authored-by: joshmeranda <joshua.meranda@gmail.com>
1001 lines
29 KiB
Go
1001 lines
29 KiB
Go
package ext
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/stretchr/testify/require"
|
|
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
|
"k8s.io/apimachinery/pkg/runtime"
|
|
"k8s.io/apimachinery/pkg/runtime/schema"
|
|
"k8s.io/apimachinery/pkg/runtime/serializer"
|
|
"k8s.io/apimachinery/pkg/watch"
|
|
"k8s.io/apiserver/pkg/authentication/authenticator"
|
|
"k8s.io/apiserver/pkg/authentication/user"
|
|
"k8s.io/apiserver/pkg/authorization/authorizer"
|
|
regrest "k8s.io/apiserver/pkg/registry/rest"
|
|
"k8s.io/client-go/dynamic"
|
|
"k8s.io/client-go/rest"
|
|
)
|
|
|
|
func authAsAdmin(req *http.Request) (*authenticator.Response, bool, error) {
|
|
return &authenticator.Response{
|
|
User: &user.DefaultInfo{
|
|
Name: "system:masters",
|
|
Groups: []string{"system:masters"},
|
|
},
|
|
}, true, nil
|
|
}
|
|
|
|
func authzAllowAll(ctx context.Context, a authorizer.Attributes) (authorizer.Decision, string, error) {
|
|
return authorizer.DecisionAllow, "", nil
|
|
}
|
|
|
|
func TestStore(t *testing.T) {
|
|
scheme := runtime.NewScheme()
|
|
AddToScheme(scheme)
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
|
|
ln, err := (&net.ListenConfig{}).Listen(ctx, "tcp", ":0")
|
|
require.NoError(t, err)
|
|
|
|
store := newDefaultTestStore()
|
|
store.items = make(map[string]*TestType)
|
|
|
|
extensionAPIServer, err := setupExtensionAPIServer(t, scheme, store, func(opts *ExtensionAPIServerOptions) {
|
|
opts.Listener = ln
|
|
opts.Authorizer = authorizer.AuthorizerFunc(authzAllowAll)
|
|
opts.Authenticator = authenticator.RequestFunc(authAsAdmin)
|
|
}, nil)
|
|
require.NoError(t, err)
|
|
|
|
ts := httptest.NewServer(extensionAPIServer)
|
|
defer ts.Close()
|
|
|
|
recWatch, err := createRecordingWatcher(scheme, testTypeGV.WithResource("testtypes"), ts.URL)
|
|
require.NoError(t, err)
|
|
|
|
updatedObj := testTypeFixture.DeepCopy()
|
|
updatedObj.Annotations = map[string]string{
|
|
"foo": "bar",
|
|
}
|
|
|
|
updatedObjList := testTypeListFixture.DeepCopy()
|
|
updatedObjList.Items = []TestType{*updatedObj}
|
|
|
|
emptyList := testTypeListFixture.DeepCopy()
|
|
emptyList.Items = []TestType{}
|
|
|
|
createRequest := func(method string, path string, obj any) *http.Request {
|
|
var body io.Reader
|
|
if obj != nil {
|
|
raw, err := json.Marshal(obj)
|
|
require.NoError(t, err)
|
|
body = bytes.NewReader(raw)
|
|
}
|
|
return httptest.NewRequest(method, path, body)
|
|
}
|
|
|
|
tests := []struct {
|
|
name string
|
|
request *http.Request
|
|
newType any
|
|
expectedStatusCode int
|
|
expectedBody any
|
|
}{
|
|
{
|
|
name: "delete not existing",
|
|
request: createRequest(http.MethodDelete, "/apis/ext.cattle.io/v1/testtypes/foo", nil),
|
|
expectedStatusCode: http.StatusNotFound,
|
|
},
|
|
{
|
|
name: "get empty list",
|
|
request: createRequest(http.MethodGet, "/apis/ext.cattle.io/v1/testtypes", nil),
|
|
newType: &TestTypeList{},
|
|
expectedStatusCode: http.StatusOK,
|
|
expectedBody: emptyList,
|
|
},
|
|
{
|
|
name: "create testtype",
|
|
request: createRequest(http.MethodPost, "/apis/ext.cattle.io/v1/testtypes", testTypeFixture.DeepCopy()),
|
|
newType: &TestType{},
|
|
expectedStatusCode: http.StatusCreated,
|
|
expectedBody: &testTypeFixture,
|
|
},
|
|
{
|
|
name: "get non-empty list",
|
|
request: createRequest(http.MethodGet, "/apis/ext.cattle.io/v1/testtypes", nil),
|
|
newType: &TestTypeList{},
|
|
expectedStatusCode: http.StatusOK,
|
|
expectedBody: &testTypeListFixture,
|
|
},
|
|
{
|
|
name: "get specific object",
|
|
request: createRequest(http.MethodGet, "/apis/ext.cattle.io/v1/testtypes/foo", nil),
|
|
newType: &TestType{},
|
|
expectedStatusCode: http.StatusOK,
|
|
expectedBody: &testTypeFixture,
|
|
},
|
|
{
|
|
name: "update",
|
|
request: createRequest(http.MethodPut, "/apis/ext.cattle.io/v1/testtypes/foo", updatedObj.DeepCopy()),
|
|
newType: &TestType{},
|
|
expectedStatusCode: http.StatusOK,
|
|
expectedBody: updatedObj,
|
|
},
|
|
{
|
|
name: "get updated",
|
|
request: createRequest(http.MethodGet, "/apis/ext.cattle.io/v1/testtypes", nil),
|
|
newType: &TestTypeList{},
|
|
expectedStatusCode: http.StatusOK,
|
|
expectedBody: updatedObjList,
|
|
},
|
|
{
|
|
name: "delete",
|
|
request: createRequest(http.MethodDelete, "/apis/ext.cattle.io/v1/testtypes/foo", nil),
|
|
newType: &TestType{},
|
|
expectedStatusCode: http.StatusOK,
|
|
expectedBody: updatedObj,
|
|
},
|
|
{
|
|
name: "delete not found",
|
|
request: createRequest(http.MethodDelete, "/apis/ext.cattle.io/v1/testtypes/foo", nil),
|
|
expectedStatusCode: http.StatusNotFound,
|
|
},
|
|
{
|
|
name: "get not found",
|
|
request: createRequest(http.MethodGet, "/apis/ext.cattle.io/v1/testtypes/foo", nil),
|
|
expectedStatusCode: http.StatusNotFound,
|
|
},
|
|
{
|
|
name: "get empty list again",
|
|
request: createRequest(http.MethodGet, "/apis/ext.cattle.io/v1/testtypes", nil),
|
|
newType: &TestTypeList{},
|
|
expectedStatusCode: http.StatusOK,
|
|
expectedBody: emptyList,
|
|
},
|
|
{
|
|
name: "create via update",
|
|
newType: &TestType{},
|
|
request: createRequest(http.MethodPut, "/apis/ext.cattle.io/v1/testtypes/foo", testTypeFixture.DeepCopy()),
|
|
expectedStatusCode: http.StatusCreated,
|
|
expectedBody: &testTypeFixture,
|
|
},
|
|
}
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
req := test.request
|
|
w := httptest.NewRecorder()
|
|
|
|
extensionAPIServer.ServeHTTP(w, req)
|
|
|
|
resp := w.Result()
|
|
body, _ := io.ReadAll(resp.Body)
|
|
|
|
require.Equal(t, test.expectedStatusCode, resp.StatusCode)
|
|
if test.expectedBody != nil && test.newType != nil {
|
|
err = json.Unmarshal(body, test.newType)
|
|
require.NoError(t, err)
|
|
require.Equal(t, test.expectedBody, test.newType)
|
|
}
|
|
})
|
|
}
|
|
|
|
// Possibly flaky, find a better way to wait for all events
|
|
time.Sleep(1 * time.Second)
|
|
expectedEvents := []watch.Event{
|
|
{Type: watch.Added, Object: testTypeFixture.DeepCopy()},
|
|
{Type: watch.Modified, Object: updatedObj.DeepCopy()},
|
|
{Type: watch.Deleted, Object: updatedObj.DeepCopy()},
|
|
{Type: watch.Added, Object: testTypeFixture.DeepCopy()},
|
|
}
|
|
|
|
events := recWatch.getEvents()
|
|
require.Equal(t, len(expectedEvents), len(events))
|
|
|
|
for i, event := range events {
|
|
raw, err := json.Marshal(event.Object)
|
|
require.NoError(t, err)
|
|
|
|
obj := &TestType{}
|
|
err = json.Unmarshal(raw, obj)
|
|
require.NoError(t, err)
|
|
|
|
convertedEvent := watch.Event{
|
|
Type: event.Type,
|
|
Object: obj,
|
|
}
|
|
|
|
require.Equal(t, expectedEvents[i], convertedEvent)
|
|
}
|
|
}
|
|
|
|
// This store tests when there's only a subset of verbs supported
|
|
type partialStorage struct {
|
|
gvk schema.GroupVersionKind
|
|
}
|
|
|
|
// New implements [regrest.Storage]
|
|
func (t *partialStorage) New() runtime.Object {
|
|
obj := &TestType{}
|
|
obj.GetObjectKind().SetGroupVersionKind(t.gvk)
|
|
return obj
|
|
}
|
|
|
|
// Destroy implements [regrest.Storage]
|
|
func (t *partialStorage) Destroy() {
|
|
}
|
|
|
|
// GetSingularName implements [regrest.SingularNameProvider]
|
|
func (t *partialStorage) GetSingularName() string {
|
|
return "testtype"
|
|
}
|
|
|
|
// NamespaceScoped implements [regrest.Scoper]
|
|
func (t *partialStorage) NamespaceScoped() bool {
|
|
return false
|
|
}
|
|
|
|
// GroupVersionKind implements [regrest.GroupVersionKindProvider]
|
|
func (t *partialStorage) GroupVersionKind(_ schema.GroupVersion) schema.GroupVersionKind {
|
|
return t.gvk
|
|
}
|
|
|
|
func (s *partialStorage) Create(ctx context.Context, obj runtime.Object, createValidation regrest.ValidateObjectFunc, options *metav1.CreateOptions) (runtime.Object, error) {
|
|
if createValidation != nil {
|
|
err := createValidation(ctx, obj)
|
|
if err != nil {
|
|
return obj, err
|
|
}
|
|
}
|
|
|
|
return nil, nil
|
|
}
|
|
|
|
// The POC had a bug where multiple resources couldn't be installed so we're
|
|
// testing this here
|
|
func TestDiscoveryAndOpenAPI(t *testing.T) {
|
|
scheme := runtime.NewScheme()
|
|
AddToScheme(scheme)
|
|
|
|
differentVersion := schema.GroupVersion{
|
|
Group: "ext.cattle.io",
|
|
Version: "v2",
|
|
}
|
|
|
|
differentGroupVersion := schema.GroupVersion{
|
|
Group: "ext2.cattle.io",
|
|
Version: "v3",
|
|
}
|
|
|
|
partialGroupVersion := schema.GroupVersion{
|
|
Group: "ext.cattle.io",
|
|
Version: "v4",
|
|
}
|
|
scheme.AddKnownTypes(differentVersion, &TestType{}, &TestTypeList{})
|
|
scheme.AddKnownTypes(differentGroupVersion, &TestType{}, &TestTypeList{})
|
|
scheme.AddKnownTypes(partialGroupVersion, &TestType{}, &TestTypeList{})
|
|
metav1.AddToGroupVersion(scheme, differentVersion)
|
|
metav1.AddToGroupVersion(scheme, differentGroupVersion)
|
|
metav1.AddToGroupVersion(scheme, partialGroupVersion)
|
|
|
|
ln, err := (&net.ListenConfig{}).Listen(context.Background(), "tcp", ":0")
|
|
require.NoError(t, err)
|
|
|
|
store := newDefaultTestStore()
|
|
extensionAPIServer, err := setupExtensionAPIServer(t, scheme, store, func(opts *ExtensionAPIServerOptions) {
|
|
opts.Listener = ln
|
|
opts.Authorizer = authorizer.AuthorizerFunc(authzAllowAll)
|
|
opts.Authenticator = authenticator.RequestFunc(authAsAdmin)
|
|
}, func(s *ExtensionAPIServer) error {
|
|
err = s.Install("testtypeothers", testTypeGV.WithKind("TestTypeOther"), &testStore[*TestTypeOther, *TestTypeOtherList]{
|
|
singular: "testtypeother",
|
|
objT: &TestTypeOther{},
|
|
objListT: &TestTypeOtherList{},
|
|
gvk: testTypeGV.WithKind("TestTypeOther"),
|
|
gvr: schema.GroupVersionResource{Group: testTypeGV.Group, Version: testTypeGV.Version, Resource: "testtypes"},
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
err = s.Install("testtypes", differentVersion.WithKind("TestType"), &testStore[*TestType, *TestTypeList]{
|
|
singular: "testtype",
|
|
objT: &TestType{},
|
|
objListT: &TestTypeList{},
|
|
gvk: differentVersion.WithKind("TestType"),
|
|
gvr: schema.GroupVersionResource{Group: differentVersion.Group, Version: differentVersion.Version, Resource: "testtypes"},
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
err = s.Install("testtypes", differentGroupVersion.WithKind("TestType"), &testStore[*TestType, *TestTypeList]{
|
|
singular: "testtype",
|
|
objT: &TestType{},
|
|
objListT: &TestTypeList{},
|
|
gvk: differentGroupVersion.WithKind("TestType"),
|
|
gvr: schema.GroupVersionResource{Group: differentGroupVersion.Group, Version: differentVersion.Version, Resource: "testtypes"},
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
err = s.Install("testtypes", partialGroupVersion.WithKind("TestType"), &partialStorage{
|
|
gvk: partialGroupVersion.WithKind("TestType"),
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
return nil
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
tests := []struct {
|
|
path string
|
|
got any
|
|
expectedStatusCode int
|
|
expectedBody any
|
|
compareFunc func(*testing.T, any)
|
|
}{
|
|
{
|
|
path: "/apis",
|
|
got: &metav1.APIGroupList{},
|
|
expectedStatusCode: http.StatusOK,
|
|
// This is needed because the k8s.io/apiserver library loops over the apigroups
|
|
compareFunc: func(t *testing.T, gotObj any) {
|
|
apiGroupList, ok := gotObj.(*metav1.APIGroupList)
|
|
require.True(t, ok)
|
|
|
|
expectedAPIGroupList := &metav1.APIGroupList{
|
|
TypeMeta: metav1.TypeMeta{
|
|
Kind: "APIGroupList",
|
|
},
|
|
Groups: []metav1.APIGroup{
|
|
{
|
|
Name: "ext.cattle.io",
|
|
Versions: []metav1.GroupVersionForDiscovery{
|
|
{
|
|
GroupVersion: "ext.cattle.io/v4",
|
|
Version: "v4",
|
|
},
|
|
{
|
|
GroupVersion: "ext.cattle.io/v2",
|
|
Version: "v2",
|
|
},
|
|
{
|
|
GroupVersion: "ext.cattle.io/v1",
|
|
Version: "v1",
|
|
},
|
|
},
|
|
PreferredVersion: metav1.GroupVersionForDiscovery{
|
|
GroupVersion: "ext.cattle.io/v2",
|
|
Version: "v2",
|
|
},
|
|
},
|
|
{
|
|
Name: "ext2.cattle.io",
|
|
Versions: []metav1.GroupVersionForDiscovery{
|
|
{
|
|
GroupVersion: "ext2.cattle.io/v3",
|
|
Version: "v3",
|
|
},
|
|
},
|
|
PreferredVersion: metav1.GroupVersionForDiscovery{
|
|
GroupVersion: "ext2.cattle.io/v3",
|
|
Version: "v3",
|
|
},
|
|
},
|
|
},
|
|
}
|
|
sortAPIGroupList(apiGroupList)
|
|
sortAPIGroupList(expectedAPIGroupList)
|
|
require.Equal(t, expectedAPIGroupList, apiGroupList)
|
|
},
|
|
},
|
|
{
|
|
path: "/apis/ext.cattle.io",
|
|
got: &metav1.APIGroup{},
|
|
expectedStatusCode: http.StatusOK,
|
|
expectedBody: &metav1.APIGroup{
|
|
TypeMeta: metav1.TypeMeta{
|
|
Kind: "APIGroup",
|
|
APIVersion: "v1",
|
|
},
|
|
Name: "ext.cattle.io",
|
|
Versions: []metav1.GroupVersionForDiscovery{
|
|
{
|
|
GroupVersion: "ext.cattle.io/v2",
|
|
Version: "v2",
|
|
},
|
|
{
|
|
GroupVersion: "ext.cattle.io/v4",
|
|
Version: "v4",
|
|
},
|
|
{
|
|
GroupVersion: "ext.cattle.io/v1",
|
|
Version: "v1",
|
|
},
|
|
},
|
|
PreferredVersion: metav1.GroupVersionForDiscovery{
|
|
GroupVersion: "ext.cattle.io/v2",
|
|
Version: "v2",
|
|
},
|
|
},
|
|
},
|
|
{
|
|
path: "/apis/ext2.cattle.io",
|
|
got: &metav1.APIGroup{},
|
|
expectedStatusCode: http.StatusOK,
|
|
expectedBody: &metav1.APIGroup{
|
|
TypeMeta: metav1.TypeMeta{
|
|
Kind: "APIGroup",
|
|
APIVersion: "v1",
|
|
},
|
|
Name: "ext2.cattle.io",
|
|
Versions: []metav1.GroupVersionForDiscovery{
|
|
{
|
|
GroupVersion: "ext2.cattle.io/v3",
|
|
Version: "v3",
|
|
},
|
|
},
|
|
PreferredVersion: metav1.GroupVersionForDiscovery{
|
|
GroupVersion: "ext2.cattle.io/v3",
|
|
Version: "v3",
|
|
},
|
|
},
|
|
},
|
|
{
|
|
path: "/apis/ext.cattle.io/v1",
|
|
got: &metav1.APIResourceList{},
|
|
expectedStatusCode: http.StatusOK,
|
|
expectedBody: &metav1.APIResourceList{
|
|
TypeMeta: metav1.TypeMeta{
|
|
Kind: "APIResourceList",
|
|
APIVersion: "v1",
|
|
},
|
|
GroupVersion: "ext.cattle.io/v1",
|
|
APIResources: []metav1.APIResource{
|
|
{
|
|
Name: "testtypeothers",
|
|
SingularName: "testtypeother",
|
|
Namespaced: false,
|
|
Kind: "TestTypeOther",
|
|
Group: "ext.cattle.io",
|
|
Version: "v1",
|
|
Verbs: metav1.Verbs{
|
|
"create", "delete", "get", "list", "patch", "update", "watch",
|
|
},
|
|
},
|
|
{
|
|
Name: "testtypes",
|
|
SingularName: "testtype",
|
|
Namespaced: false,
|
|
Kind: "TestType",
|
|
Group: "ext.cattle.io",
|
|
Version: "v1",
|
|
Verbs: metav1.Verbs{
|
|
"create", "delete", "get", "list", "patch", "update", "watch",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
{
|
|
path: "/apis/ext.cattle.io/v2",
|
|
got: &metav1.APIResourceList{},
|
|
expectedStatusCode: http.StatusOK,
|
|
expectedBody: &metav1.APIResourceList{
|
|
TypeMeta: metav1.TypeMeta{
|
|
Kind: "APIResourceList",
|
|
APIVersion: "v1",
|
|
},
|
|
GroupVersion: "ext.cattle.io/v2",
|
|
APIResources: []metav1.APIResource{
|
|
{
|
|
Name: "testtypes",
|
|
SingularName: "testtype",
|
|
Namespaced: false,
|
|
Kind: "TestType",
|
|
Group: "ext.cattle.io",
|
|
Version: "v2",
|
|
Verbs: metav1.Verbs{
|
|
"create", "delete", "get", "list", "patch", "update", "watch",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
{
|
|
path: "/apis/ext2.cattle.io/v3",
|
|
got: &metav1.APIResourceList{},
|
|
expectedStatusCode: http.StatusOK,
|
|
expectedBody: &metav1.APIResourceList{
|
|
TypeMeta: metav1.TypeMeta{
|
|
Kind: "APIResourceList",
|
|
APIVersion: "v1",
|
|
},
|
|
GroupVersion: "ext2.cattle.io/v3",
|
|
APIResources: []metav1.APIResource{
|
|
{
|
|
Name: "testtypes",
|
|
SingularName: "testtype",
|
|
Namespaced: false,
|
|
Kind: "TestType",
|
|
Group: "ext2.cattle.io",
|
|
Version: "v3",
|
|
Verbs: metav1.Verbs{
|
|
"create", "delete", "get", "list", "patch", "update", "watch",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
{
|
|
path: "/apis/ext.cattle.io/v4",
|
|
got: &metav1.APIResourceList{},
|
|
expectedStatusCode: http.StatusOK,
|
|
expectedBody: &metav1.APIResourceList{
|
|
TypeMeta: metav1.TypeMeta{
|
|
Kind: "APIResourceList",
|
|
APIVersion: "v1",
|
|
},
|
|
GroupVersion: "ext.cattle.io/v4",
|
|
APIResources: []metav1.APIResource{
|
|
{
|
|
Name: "testtypes",
|
|
SingularName: "testtype",
|
|
Namespaced: false,
|
|
Kind: "TestType",
|
|
Group: "ext.cattle.io",
|
|
Version: "v4",
|
|
// Only the create verb is supported for this store
|
|
Verbs: metav1.Verbs{
|
|
"create",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
{
|
|
path: "/openapi/v2",
|
|
expectedStatusCode: http.StatusOK,
|
|
},
|
|
{
|
|
path: "/openapi/v3",
|
|
expectedStatusCode: http.StatusOK,
|
|
},
|
|
{
|
|
path: "/openapi/v3/apis",
|
|
expectedStatusCode: http.StatusOK,
|
|
},
|
|
{
|
|
path: "/openapi/v3/apis/ext.cattle.io",
|
|
expectedStatusCode: http.StatusOK,
|
|
},
|
|
{
|
|
path: "/openapi/v3/apis/ext.cattle.io/v1",
|
|
expectedStatusCode: http.StatusOK,
|
|
},
|
|
{
|
|
path: "/openapi/v3/apis/ext.cattle.io/v2",
|
|
expectedStatusCode: http.StatusOK,
|
|
},
|
|
{
|
|
path: "/openapi/v3/apis/ext2.cattle.io",
|
|
expectedStatusCode: http.StatusOK,
|
|
},
|
|
{
|
|
path: "/openapi/v3/apis/ext2.cattle.io/v3",
|
|
expectedStatusCode: http.StatusOK,
|
|
},
|
|
}
|
|
for _, test := range tests {
|
|
name := strings.ReplaceAll(test.path, "/", "_")
|
|
t.Run(name, func(t *testing.T) {
|
|
req := httptest.NewRequest(http.MethodGet, test.path, nil)
|
|
w := httptest.NewRecorder()
|
|
|
|
extensionAPIServer.ServeHTTP(w, req)
|
|
|
|
resp := w.Result()
|
|
body, _ := io.ReadAll(resp.Body)
|
|
|
|
require.Equal(t, test.expectedStatusCode, resp.StatusCode)
|
|
if test.expectedBody != nil && test.got != nil {
|
|
err = json.Unmarshal(body, test.got)
|
|
require.NoError(t, err)
|
|
require.Equal(t, test.expectedBody, test.got)
|
|
}
|
|
if test.got != nil && test.compareFunc != nil {
|
|
err = json.Unmarshal(body, test.got)
|
|
require.NoError(t, err)
|
|
test.compareFunc(t, test.got)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// Because the k8s.io/apiserver library has non-deterministic map iteration, changing the order of groups and versions
|
|
func sortAPIGroupList(list *metav1.APIGroupList) {
|
|
for _, group := range list.Groups {
|
|
sort.Slice(group.Versions, func(i, j int) bool {
|
|
return group.Versions[i].GroupVersion > group.Versions[j].GroupVersion
|
|
})
|
|
}
|
|
sort.Slice(list.Groups, func(i, j int) bool {
|
|
return list.Groups[i].Name > list.Groups[j].Name
|
|
})
|
|
}
|
|
|
|
func TestNoStore(t *testing.T) {
|
|
scheme := runtime.NewScheme()
|
|
codecs := serializer.NewCodecFactory(scheme)
|
|
|
|
ln, err := (&net.ListenConfig{}).Listen(context.Background(), "tcp", ":0")
|
|
require.NoError(t, err)
|
|
|
|
opts := ExtensionAPIServerOptions{
|
|
GetOpenAPIDefinitions: getOpenAPIDefinitions,
|
|
Listener: ln,
|
|
Authorizer: authorizer.AuthorizerFunc(authzAllowAll),
|
|
Authenticator: authenticator.RequestFunc(authAsAdmin),
|
|
}
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
|
|
extensionAPIServer, err := NewExtensionAPIServer(scheme, codecs, opts)
|
|
require.NoError(t, err)
|
|
|
|
err = extensionAPIServer.Run(ctx)
|
|
require.NoError(t, err)
|
|
}
|
|
|
|
func setupExtensionAPIServer(
|
|
t *testing.T,
|
|
scheme *runtime.Scheme,
|
|
store regrest.Storage,
|
|
optionSetter func(*ExtensionAPIServerOptions),
|
|
extensionAPIServerSetter func(*ExtensionAPIServer) error,
|
|
) (*ExtensionAPIServer, error) {
|
|
fn := func(e *ExtensionAPIServer) error {
|
|
err := e.Install("testtypes", testTypeGV.WithKind("TestType"), store)
|
|
if err != nil {
|
|
return fmt.Errorf("InstallStore: %w", err)
|
|
}
|
|
if extensionAPIServerSetter != nil {
|
|
return extensionAPIServerSetter(e)
|
|
}
|
|
return nil
|
|
}
|
|
return setupExtensionAPIServerNoStore(t, scheme, optionSetter, fn)
|
|
}
|
|
|
|
func setupExtensionAPIServerNoStore(
|
|
t *testing.T,
|
|
scheme *runtime.Scheme,
|
|
optionSetter func(*ExtensionAPIServerOptions),
|
|
extensionAPIServerSetter func(*ExtensionAPIServer) error,
|
|
) (*ExtensionAPIServer, error) {
|
|
|
|
addToSchemeTest(scheme)
|
|
codecs := serializer.NewCodecFactory(scheme)
|
|
|
|
opts := ExtensionAPIServerOptions{
|
|
GetOpenAPIDefinitions: getOpenAPIDefinitions,
|
|
OpenAPIDefinitionNameReplacements: map[string]string{
|
|
"com.github.rancher.steve.pkg.ext": "io.cattle.ext.v1",
|
|
},
|
|
}
|
|
if optionSetter != nil {
|
|
optionSetter(&opts)
|
|
}
|
|
extensionAPIServer, err := NewExtensionAPIServer(scheme, codecs, opts)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if extensionAPIServerSetter != nil {
|
|
err = extensionAPIServerSetter(extensionAPIServer)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("extensionAPIServerSetter: %w", err)
|
|
}
|
|
}
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
t.Cleanup(func() {
|
|
cancel()
|
|
})
|
|
|
|
err = extensionAPIServer.Run(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return extensionAPIServer, nil
|
|
}
|
|
|
|
type recordingWatcher struct {
|
|
ch <-chan watch.Event
|
|
stop func()
|
|
}
|
|
|
|
func (w *recordingWatcher) getEvents() []watch.Event {
|
|
w.stop()
|
|
events := []watch.Event{}
|
|
for event := range w.ch {
|
|
events = append(events, event)
|
|
}
|
|
return events
|
|
}
|
|
|
|
func createRecordingWatcher(scheme *runtime.Scheme, gvr schema.GroupVersionResource, url string) (*recordingWatcher, error) {
|
|
codecs := serializer.NewCodecFactory(scheme)
|
|
|
|
gv := gvr.GroupVersion()
|
|
client, err := dynamic.NewForConfig(&rest.Config{
|
|
Host: url,
|
|
APIPath: "/apis",
|
|
ContentConfig: rest.ContentConfig{
|
|
NegotiatedSerializer: codecs,
|
|
GroupVersion: &gv,
|
|
},
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
opts := metav1.ListOptions{
|
|
Watch: true,
|
|
}
|
|
myWatch, err := client.Resource(gvr).Watch(context.Background(), opts)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
// Should be plenty enough for most tests
|
|
ch := make(chan watch.Event, 100)
|
|
go func() {
|
|
for event := range myWatch.ResultChan() {
|
|
ch <- event
|
|
}
|
|
close(ch)
|
|
}()
|
|
return &recordingWatcher{
|
|
ch: ch,
|
|
stop: myWatch.Stop,
|
|
}, nil
|
|
}
|
|
|
|
// This store tests the printed columns functionality
|
|
type customColumnsStore struct {
|
|
*testStore[*TestType, *TestTypeList]
|
|
|
|
lock sync.Mutex
|
|
columns []metav1.TableColumnDefinition
|
|
convertFn func(obj *TestType) []string
|
|
}
|
|
|
|
func (s *customColumnsStore) ConvertToTable(ctx context.Context, object runtime.Object, tableOptions runtime.Object) (*metav1.Table, error) {
|
|
s.lock.Lock()
|
|
defer s.lock.Unlock()
|
|
return ConvertToTable(ctx, object, tableOptions, s.testStore.gvr.GroupResource(), s.columns, s.convertFn)
|
|
}
|
|
|
|
func (s *customColumnsStore) Set(columns []metav1.TableColumnDefinition, convertFn func(obj *TestType) []string) {
|
|
s.lock.Lock()
|
|
defer s.lock.Unlock()
|
|
s.columns = columns
|
|
s.convertFn = convertFn
|
|
}
|
|
|
|
func TestCustomColumns(t *testing.T) {
|
|
scheme := runtime.NewScheme()
|
|
AddToScheme(scheme)
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
|
|
ln, err := (&net.ListenConfig{}).Listen(ctx, "tcp", ":0")
|
|
require.NoError(t, err)
|
|
|
|
store := &customColumnsStore{
|
|
testStore: newDefaultTestStore(),
|
|
}
|
|
|
|
extensionAPIServer, err := setupExtensionAPIServerNoStore(t, scheme, func(opts *ExtensionAPIServerOptions) {
|
|
opts.Listener = ln
|
|
opts.Authorizer = authorizer.AuthorizerFunc(authzAllowAll)
|
|
opts.Authenticator = authenticator.RequestFunc(authAsAdmin)
|
|
}, func(s *ExtensionAPIServer) error {
|
|
err := s.Install("testtypes", testTypeGV.WithKind("TestType"), store)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
})
|
|
|
|
require.NoError(t, err)
|
|
|
|
ts := httptest.NewServer(extensionAPIServer)
|
|
defer ts.Close()
|
|
|
|
createRequest := func(path string) *http.Request {
|
|
req := httptest.NewRequest(http.MethodGet, path, nil)
|
|
// This asks the apiserver to give back a metav1.Table for List and Get operations
|
|
req.Header.Add("Accept", "application/json;as=Table;v=v1;g=meta.k8s.io")
|
|
return req
|
|
}
|
|
|
|
columns := []metav1.TableColumnDefinition{
|
|
{
|
|
Name: "Name",
|
|
Type: "name",
|
|
},
|
|
{
|
|
Name: "Foo",
|
|
Type: "string",
|
|
},
|
|
{
|
|
Name: "Bar",
|
|
Type: "number",
|
|
},
|
|
}
|
|
convertFn := func(obj *TestType) []string {
|
|
return []string{
|
|
"the name is " + obj.GetName(),
|
|
"the foo value",
|
|
"the bar value",
|
|
}
|
|
}
|
|
|
|
tests := []struct {
|
|
name string
|
|
requests []*http.Request
|
|
columns []metav1.TableColumnDefinition
|
|
convertFn func(obj *TestType) []string
|
|
expectedStatusCode int
|
|
expectedBody any
|
|
}{
|
|
{
|
|
name: "default",
|
|
requests: []*http.Request{
|
|
createRequest("/apis/ext.cattle.io/v1/testtypes"),
|
|
createRequest("/apis/ext.cattle.io/v1/testtypes/foo"),
|
|
},
|
|
expectedStatusCode: http.StatusOK,
|
|
expectedBody: &metav1.Table{
|
|
TypeMeta: metav1.TypeMeta{Kind: "Table", APIVersion: "meta.k8s.io/v1"},
|
|
ColumnDefinitions: []metav1.TableColumnDefinition{
|
|
{Name: "Name", Type: "string", Format: "name", Description: "Name must be unique within a namespace. Is required when creating resources, although some resources may allow a client to request the generation of an appropriate name automatically. Name is primarily intended for creation idempotence and configuration definition. Cannot be updated. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names#names"},
|
|
{Name: "Created At", Type: "date", Description: "CreationTimestamp is a timestamp representing the server time when this object was created. It is not guaranteed to be set in happens-before order across separate operations. Clients may not set this value. It is represented in RFC3339 form and is in UTC.\n\nPopulated by the system. Read-only. Null for lists. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#metadata"},
|
|
},
|
|
Rows: []metav1.TableRow{
|
|
{
|
|
Cells: []any{"foo", "0001-01-01T00:00:00Z"},
|
|
Object: runtime.RawExtension{
|
|
Raw: []byte(`{"kind":"PartialObjectMetadata","apiVersion":"meta.k8s.io/v1","metadata":{"name":"foo","creationTimestamp":null}}`),
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: "custom include object default and metadata",
|
|
requests: []*http.Request{
|
|
createRequest("/apis/ext.cattle.io/v1/testtypes"),
|
|
createRequest("/apis/ext.cattle.io/v1/testtypes/foo"),
|
|
createRequest("/apis/ext.cattle.io/v1/testtypes?includeObject=Metadata"),
|
|
createRequest("/apis/ext.cattle.io/v1/testtypes/foo?includeObject=Metadata"),
|
|
},
|
|
columns: columns,
|
|
convertFn: convertFn,
|
|
expectedStatusCode: http.StatusOK,
|
|
expectedBody: &metav1.Table{
|
|
TypeMeta: metav1.TypeMeta{Kind: "Table", APIVersion: "meta.k8s.io/v1"},
|
|
ColumnDefinitions: []metav1.TableColumnDefinition{
|
|
{Name: "Name", Type: "name"},
|
|
{Name: "Foo", Type: "string"},
|
|
{Name: "Bar", Type: "number"},
|
|
},
|
|
Rows: []metav1.TableRow{
|
|
{
|
|
Cells: []any{"the name is foo", "the foo value", "the bar value"},
|
|
Object: runtime.RawExtension{
|
|
Raw: []byte(`{"kind":"PartialObjectMetadata","apiVersion":"meta.k8s.io/v1","metadata":{"name":"foo","creationTimestamp":null}}`),
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: "custom include object None",
|
|
requests: []*http.Request{
|
|
createRequest("/apis/ext.cattle.io/v1/testtypes?includeObject=None"),
|
|
createRequest("/apis/ext.cattle.io/v1/testtypes/foo?includeObject=None"),
|
|
},
|
|
columns: columns,
|
|
convertFn: convertFn,
|
|
expectedStatusCode: http.StatusOK,
|
|
expectedBody: &metav1.Table{
|
|
TypeMeta: metav1.TypeMeta{Kind: "Table", APIVersion: "meta.k8s.io/v1"},
|
|
ColumnDefinitions: []metav1.TableColumnDefinition{
|
|
{Name: "Name", Type: "name"},
|
|
{Name: "Foo", Type: "string"},
|
|
{Name: "Bar", Type: "number"},
|
|
},
|
|
Rows: []metav1.TableRow{
|
|
{
|
|
Cells: []any{"the name is foo", "the foo value", "the bar value"},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: "custom include object Object",
|
|
requests: []*http.Request{
|
|
createRequest("/apis/ext.cattle.io/v1/testtypes?includeObject=Object"),
|
|
createRequest("/apis/ext.cattle.io/v1/testtypes/foo?includeObject=Object"),
|
|
},
|
|
columns: columns,
|
|
convertFn: convertFn,
|
|
expectedStatusCode: http.StatusOK,
|
|
expectedBody: &metav1.Table{
|
|
TypeMeta: metav1.TypeMeta{Kind: "Table", APIVersion: "meta.k8s.io/v1"},
|
|
ColumnDefinitions: []metav1.TableColumnDefinition{
|
|
{Name: "Name", Type: "name"},
|
|
{Name: "Foo", Type: "string"},
|
|
{Name: "Bar", Type: "number"},
|
|
},
|
|
Rows: []metav1.TableRow{
|
|
{
|
|
Cells: []any{"the name is foo", "the foo value", "the bar value"},
|
|
Object: runtime.RawExtension{
|
|
Raw: []byte(`{"kind":"TestType","apiVersion":"ext.cattle.io/v1","metadata":{"name":"foo","creationTimestamp":null}}`),
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
if test.columns != nil {
|
|
store.Set(test.columns, test.convertFn)
|
|
}
|
|
|
|
for _, req := range test.requests {
|
|
w := httptest.NewRecorder()
|
|
|
|
extensionAPIServer.ServeHTTP(w, req)
|
|
|
|
resp := w.Result()
|
|
body, _ := io.ReadAll(resp.Body)
|
|
|
|
require.Equal(t, test.expectedStatusCode, resp.StatusCode)
|
|
if test.expectedBody != nil {
|
|
table := &metav1.Table{}
|
|
err = json.Unmarshal(body, table)
|
|
require.NoError(t, err)
|
|
require.Equal(t, test.expectedBody, table)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
|
|
}
|