cli-utils/pkg/apply/common_test.go

460 lines
14 KiB
Go

// Copyright 2020 The Kubernetes Authors.
// SPDX-License-Identifier: Apache-2.0
package apply
import (
"bytes"
"context"
"fmt"
"io/ioutil"
"net/http"
"regexp"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
v1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/api/meta"
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"
"k8s.io/cli-runtime/pkg/resource"
dynamicfake "k8s.io/client-go/dynamic/fake"
"k8s.io/client-go/rest/fake"
clienttesting "k8s.io/client-go/testing"
"k8s.io/klog/v2"
cmdtesting "k8s.io/kubectl/pkg/cmd/testing"
"k8s.io/kubectl/pkg/scheme"
"sigs.k8s.io/cli-utils/pkg/apply/poller"
"sigs.k8s.io/cli-utils/pkg/common"
"sigs.k8s.io/cli-utils/pkg/inventory"
"sigs.k8s.io/cli-utils/pkg/kstatus/polling"
pollevent "sigs.k8s.io/cli-utils/pkg/kstatus/polling/event"
"sigs.k8s.io/cli-utils/pkg/object"
)
type inventoryInfo struct {
name string
namespace string
id string
set object.ObjMetadataSet
}
func (i inventoryInfo) toUnstructured() *unstructured.Unstructured {
invMap := make(map[string]interface{})
for _, objMeta := range i.set {
invMap[objMeta.String()] = ""
}
return &unstructured.Unstructured{
Object: map[string]interface{}{
"apiVersion": "v1",
"kind": "ConfigMap",
"metadata": map[string]interface{}{
"name": i.name,
"namespace": i.namespace,
"labels": map[string]interface{}{
common.InventoryLabel: i.id,
},
},
"data": invMap,
},
}
}
func (i inventoryInfo) toWrapped() inventory.InventoryInfo {
return inventory.WrapInventoryInfoObj(i.toUnstructured())
}
func newTestApplier(
t *testing.T,
invInfo inventoryInfo,
resources object.UnstructuredSet,
clusterObjs object.UnstructuredSet,
statusPoller poller.Poller,
) *Applier {
tf := newTestFactory(t, invInfo, resources, clusterObjs)
defer tf.Cleanup()
infoHelper := &fakeInfoHelper{
factory: tf,
}
invClient := newTestInventory(t, tf)
applier, err := NewApplier(tf, invClient)
require.NoError(t, err)
applier.StatusPoller = statusPoller
// Inject the fakeInfoHelper to allow generating Info
// objects that use the FakeRESTClient as the UnstructuredClient.
applier.infoHelper = infoHelper
return applier
}
func newTestDestroyer(
t *testing.T,
invInfo inventoryInfo,
clusterObjs object.UnstructuredSet,
statusPoller poller.Poller,
) *Destroyer {
tf := newTestFactory(t, invInfo, object.UnstructuredSet{}, clusterObjs)
defer tf.Cleanup()
invClient := newTestInventory(t, tf)
destroyer, err := NewDestroyer(tf, invClient)
require.NoError(t, err)
destroyer.StatusPoller = statusPoller
return destroyer
}
func newTestInventory(
t *testing.T,
tf *cmdtesting.TestFactory,
) inventory.InventoryClient {
// Use an InventoryClient with a fakeInfoHelper to allow generating Info
// objects that use the FakeRESTClient as the UnstructuredClient.
invClient, err := inventory.ClusterInventoryClientFactory{}.NewInventoryClient(tf)
require.NoError(t, err)
return invClient
}
func newTestFactory(
t *testing.T,
invInfo inventoryInfo,
resourceSet object.UnstructuredSet,
clusterObjs object.UnstructuredSet,
) *cmdtesting.TestFactory {
tf := cmdtesting.NewTestFactory().WithNamespace(invInfo.namespace)
mapper, err := tf.ToRESTMapper()
require.NoError(t, err)
objMap := make(map[object.ObjMetadata]resourceInfo)
for _, r := range resourceSet {
objMeta := object.UnstructuredToObjMetadata(r)
objMap[objMeta] = resourceInfo{
resource: r,
exists: false,
}
}
for _, r := range clusterObjs {
objMeta := object.UnstructuredToObjMetadata(r)
objMap[objMeta] = resourceInfo{
resource: r,
exists: true,
}
}
var objs []resourceInfo
for _, obj := range objMap {
objs = append(objs, obj)
}
handlers := []handler{
&nsHandler{},
&genericHandler{
resources: objs,
mapper: mapper,
},
}
tf.UnstructuredClient = newFakeRESTClient(t, handlers)
tf.FakeDynamicClient = fakeDynamicClient(t, mapper, invInfo, objs...)
return tf
}
type resourceInfo struct {
resource *unstructured.Unstructured
exists bool
}
// newFakeRESTClient creates a new client that uses a set of handlers to
// determine how to handle requests. For every request it will iterate through
// the handlers until it can find one that knows how to handle the request.
// This is to keep the main structure of the fake client manageable while still
// allowing different behavior for different testcases.
func newFakeRESTClient(t *testing.T, handlers []handler) *fake.RESTClient {
return &fake.RESTClient{
NegotiatedSerializer: resource.UnstructuredPlusDefaultContentConfig().NegotiatedSerializer,
Client: fake.CreateHTTPClient(func(req *http.Request) (*http.Response, error) {
klog.V(5).Infof("FakeRESTClient: handling %s request for %q", req.Method, req.URL)
for _, h := range handlers {
resp, handled, err := h.handle(t, req)
if err != nil {
t.Fatalf("unexpected error: %v", err)
return nil, nil
}
if handled {
return resp, nil
}
}
t.Fatalf("unexpected request: %#v\n%#v", req.URL, req)
return nil, nil
}),
}
}
// The handler interface allows different testcases to provide
// special handling of requests. It also allows a single handler
// to keep state between a set of related requests instead of keeping
// a single large event handler.
type handler interface {
handle(t *testing.T, req *http.Request) (*http.Response, bool, error)
}
// genericHandler provides a simple handler for resources that can
// be fetched and updated. It will simply return the given resource
// when asked for and accept patch requests.
type genericHandler struct {
resources []resourceInfo
mapper meta.RESTMapper
}
func (g *genericHandler) handle(t *testing.T, req *http.Request) (*http.Response, bool, error) {
klog.V(5).Infof("genericHandler: handling %s request for %q", req.Method, req.URL)
for _, r := range g.resources {
gvk := r.resource.GroupVersionKind()
mapping, err := g.mapper.RESTMapping(gvk.GroupKind(), gvk.Version)
if err != nil {
return nil, false, err
}
var allPath string
if mapping.Scope == meta.RESTScopeNamespace {
allPath = fmt.Sprintf("/namespaces/%s/%s", r.resource.GetNamespace(), mapping.Resource.Resource)
} else {
allPath = fmt.Sprintf("/%s", mapping.Resource.Resource)
}
singlePath := allPath + "/" + r.resource.GetName()
if req.URL.Path == singlePath && req.Method == http.MethodGet {
if r.exists {
bodyRC := ioutil.NopCloser(bytes.NewReader(toJSONBytes(t, r.resource)))
return &http.Response{StatusCode: http.StatusOK, Header: cmdtesting.DefaultHeader(), Body: bodyRC}, true, nil
}
return &http.Response{StatusCode: http.StatusNotFound, Header: cmdtesting.DefaultHeader(), Body: cmdtesting.StringBody("")}, true, nil
}
if req.URL.Path == singlePath && req.Method == http.MethodPatch {
bodyRC := ioutil.NopCloser(bytes.NewReader(toJSONBytes(t, r.resource)))
return &http.Response{StatusCode: http.StatusOK, Header: cmdtesting.DefaultHeader(), Body: bodyRC}, true, nil
}
if req.URL.Path == singlePath && req.Method == http.MethodDelete {
if r.exists {
bodyRC := ioutil.NopCloser(bytes.NewReader(toJSONBytes(t, r.resource)))
return &http.Response{StatusCode: http.StatusOK, Header: cmdtesting.DefaultHeader(), Body: bodyRC}, true, nil
}
// We're not testing DeletePropagationOrphan, so StatusOK should be
// safe. Otherwise, the status might be StatusAccepted.
// https://github.com/kubernetes/apiserver/blob/v0.22.2/pkg/endpoints/handlers/delete.go#L140
status := http.StatusOK
// Return Status object, if resource doesn't exist.
result := &metav1.Status{
Status: metav1.StatusSuccess,
Code: int32(status),
Details: &metav1.StatusDetails{
Name: r.resource.GetName(),
Kind: r.resource.GetKind(),
},
}
bodyRC := ioutil.NopCloser(bytes.NewReader(toJSONBytes(t, result)))
return &http.Response{StatusCode: status, Header: cmdtesting.DefaultHeader(), Body: bodyRC}, true, nil
}
if req.URL.Path == allPath && req.Method == http.MethodPost {
bodyRC := ioutil.NopCloser(bytes.NewReader(toJSONBytes(t, r.resource)))
return &http.Response{StatusCode: http.StatusCreated, Header: cmdtesting.DefaultHeader(), Body: bodyRC}, true, nil
}
}
return nil, false, nil
}
func newInventoryReactor(invInfo inventoryInfo) *inventoryReactor {
return &inventoryReactor{
inventoryObj: invInfo.toUnstructured(),
}
}
type inventoryReactor struct {
inventoryObj *unstructured.Unstructured
}
func (ir *inventoryReactor) updateFakeDynamicClient(fdc *dynamicfake.FakeDynamicClient) {
fdc.PrependReactor("create", "configmaps", func(action clienttesting.Action) (bool, runtime.Object, error) {
obj := *action.(clienttesting.CreateAction).GetObject().(*unstructured.Unstructured)
ir.inventoryObj = &obj
return true, nil, nil
})
fdc.PrependReactor("list", "configmaps", func(action clienttesting.Action) (bool, runtime.Object, error) {
uList := &unstructured.UnstructuredList{
Items: []unstructured.Unstructured{},
}
if ir.inventoryObj != nil {
uList.Items = append(uList.Items, *ir.inventoryObj)
}
return true, uList, nil
})
fdc.PrependReactor("get", "configmaps", func(action clienttesting.Action) (bool, runtime.Object, error) {
return true, ir.inventoryObj, nil
})
fdc.PrependReactor("update", "configmaps", func(action clienttesting.Action) (bool, runtime.Object, error) {
obj := *action.(clienttesting.UpdateAction).GetObject().(*unstructured.Unstructured)
ir.inventoryObj = &obj
return true, nil, nil
})
}
// nsHandler can handle requests for a namespace. It will behave as if
// every requested namespace exists. It simply fetches the name of the requested
// namespace from the url and creates a new namespace type with the provided
// name for the response.
type nsHandler struct{}
var (
nsPathRegex = regexp.MustCompile(`/api/v1/namespaces/([^/]+)`)
)
func (n *nsHandler) handle(t *testing.T, req *http.Request) (*http.Response, bool, error) {
match := nsPathRegex.FindStringSubmatch(req.URL.Path)
if req.Method == http.MethodGet && match != nil {
nsName := match[1]
ns := v1.Namespace{
TypeMeta: metav1.TypeMeta{
APIVersion: "v1",
Kind: "Namespace",
},
ObjectMeta: metav1.ObjectMeta{
Name: nsName,
},
}
bodyRC := ioutil.NopCloser(bytes.NewReader(toJSONBytes(t, &ns)))
return &http.Response{StatusCode: http.StatusOK, Header: cmdtesting.DefaultHeader(), Body: bodyRC}, true, nil
}
return nil, false, nil
}
type fakePoller struct {
start chan struct{}
events []pollevent.Event
}
func newFakePoller(statusEvents []pollevent.Event) *fakePoller {
return &fakePoller{
events: statusEvents,
start: make(chan struct{}),
}
}
// Start events being sent on the status channel
func (f *fakePoller) Start() {
close(f.start)
}
func (f *fakePoller) Poll(ctx context.Context, _ object.ObjMetadataSet, _ polling.Options) <-chan pollevent.Event {
eventChannel := make(chan pollevent.Event)
go func() {
defer close(eventChannel)
// wait until started to send the events
<-f.start
for _, f := range f.events {
eventChannel <- f
}
// wait until cancelled to close the event channel and exit
<-ctx.Done()
}()
return eventChannel
}
type fakeInfoHelper struct {
factory *cmdtesting.TestFactory
}
// TODO(mortent): This has too much code in common with the
// infoHelper implementation. We need to find a better way to structure
// this.
func (f *fakeInfoHelper) UpdateInfo(info *resource.Info) error {
mapper, err := f.factory.ToRESTMapper()
if err != nil {
return err
}
gvk := info.Object.GetObjectKind().GroupVersionKind()
mapping, err := mapper.RESTMapping(gvk.GroupKind(), gvk.Version)
if err != nil {
return err
}
info.Mapping = mapping
c, err := f.getClient(gvk.GroupVersion())
if err != nil {
return err
}
info.Client = c
return nil
}
func (f *fakeInfoHelper) BuildInfo(obj *unstructured.Unstructured) (*resource.Info, error) {
info := &resource.Info{
Name: obj.GetName(),
Namespace: obj.GetNamespace(),
Source: "unstructured",
Object: obj,
}
err := f.UpdateInfo(info)
return info, err
}
func (f *fakeInfoHelper) getClient(gv schema.GroupVersion) (resource.RESTClient, error) {
if f.factory.UnstructuredClientForMappingFunc != nil {
return f.factory.UnstructuredClientForMappingFunc(gv)
}
if f.factory.UnstructuredClient != nil {
return f.factory.UnstructuredClient, nil
}
return f.factory.Client, nil
}
// fakeDynamicClient returns a fake dynamic client.
func fakeDynamicClient(t *testing.T, mapper meta.RESTMapper, invInfo inventoryInfo, objs ...resourceInfo) *dynamicfake.FakeDynamicClient {
fakeClient := dynamicfake.NewSimpleDynamicClient(scheme.Scheme)
invReactor := newInventoryReactor(invInfo)
invReactor.updateFakeDynamicClient(fakeClient)
for i := range objs {
obj := objs[i]
gvk := obj.resource.GroupVersionKind()
mapping, err := mapper.RESTMapping(gvk.GroupKind(), gvk.Version)
if !assert.NoError(t, err) {
t.FailNow()
}
r := mapping.Resource.Resource
fakeClient.PrependReactor("get", r, func(clienttesting.Action) (bool, runtime.Object, error) {
if obj.exists {
return true, obj.resource, nil
}
return false, nil, nil
})
fakeClient.PrependReactor("delete", r, func(clienttesting.Action) (bool, runtime.Object, error) {
return true, nil, nil
})
}
return fakeClient
}
func toJSONBytes(t *testing.T, obj runtime.Object) []byte {
objBytes, err := runtime.Encode(unstructured.NewJSONFallbackEncoder(codec), obj)
if !assert.NoError(t, err) {
t.Fatal(err)
}
return objBytes
}