Repository: vmware-tanzu/octant
Pull request #490 (2019-12-17 19:34:20)

Handle mutliple CRD versions
CRD api has moved from v1beta1 to v1. Handle both methods.

This change also introduces a new display for CRDs. You can now see some CRD configuration and multiple versions of custom resources.

![Screen Shot 2019-12-17 at 2 36 04 PM](https://user-images.githubusercontent.com/240/71028098-9bf97e00-20da-11ea-9fa4-52f6af8ac7f0.png)



Patch:
diff --git a/changelogs/unreleased/405-bryanl b/changelogs/unreleased/405-bryanl
new file mode 100644
index 00000000..75d0af6d
--- /dev/null
+++ b/changelogs/unreleased/405-bryanl
@@ -0,0 +1,1 @@
+Support v1 and v1beta1 CRD API
diff --git a/internal/api/content_manager.go b/internal/api/content_manager.go
index 984cd810..f53c9657 100644
--- a/internal/api/content_manager.go
+++ b/internal/api/content_manager.go
@@ -97,6 +97,10 @@ func (cm *ContentManager) runUpdate(state octant.State, s OctantClient) PollerFu
 
 		contentResponse, _, err := cm.contentGenerateFunc(ctx, state)
 		if err != nil {
+			cm.logger.
+				WithErr(err).
+				With("content-path", contentPath).
+				Errorf("generate content")
 			return false
 		}
 
diff --git a/internal/describer/crd.go b/internal/describer/crd.go
index a242f804..09f4b7c0 100644
--- a/internal/describer/crd.go
+++ b/internal/describer/crd.go
@@ -7,16 +7,17 @@ package describer
 
 import (
 	"context"
+	"fmt"
 
 	"github.com/pkg/errors"
-	apiextv1beta1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1beta1"
 	"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
 	"k8s.io/apimachinery/pkg/runtime"
-	"k8s.io/apimachinery/pkg/runtime/schema"
 
 	"github.com/vmware-tanzu/octant/internal/config"
+	"github.com/vmware-tanzu/octant/internal/gvk"
 	"github.com/vmware-tanzu/octant/internal/link"
 	"github.com/vmware-tanzu/octant/internal/modules/overview/yamlviewer"
+	"github.com/vmware-tanzu/octant/internal/octant"
 	"github.com/vmware-tanzu/octant/internal/printer"
 	"github.com/vmware-tanzu/octant/internal/queryer"
 	"github.com/vmware-tanzu/octant/internal/resourceviewer"
@@ -25,7 +26,7 @@ import (
 	"github.com/vmware-tanzu/octant/pkg/view/component"
 )
 
-type crdPrinter func(ctx context.Context, crd *apiextv1beta1.CustomResourceDefinition, object *unstructured.Unstructured, options printer.Options) (component.Component, error)
+type crdPrinter func(ctx context.Context, crd, object *unstructured.Unstructured, options printer.Options) (component.Component, error)
 type resourceViewerPrinter func(ctx context.Context, object *unstructured.Unstructured, dashConfig config.Dash, q queryer.Queryer) (component.Component, error)
 type yamlPrinter func(runtime.Object) (*component.YAML, error)
 
@@ -66,14 +67,24 @@ func (c *crd) Describe(ctx context.Context, namespace string, options Options) (
 		return component.EmptyContentResponse, err
 	}
 
-	// TODO: crd.Spec.Version is incorrect. Use crd.Spec.Version instead.
-	gvk := schema.GroupVersionKind{
-		Group:   crd.Spec.Group,
-		Version: crd.Spec.Version,
-		Kind:    crd.Spec.Names.Kind,
+	octantCRD, err := octant.NewCustomResourceDefinition(crd)
+	if err != nil {
+		return component.EmptyContentResponse, err
+	}
+
+	crdVersions, err := octantCRD.Versions()
+	if err != nil {
+		return component.EmptyContentResponse, fmt.Errorf("get versions for crd %s: %w", crd.GetName(), err)
+	} else if len(crdVersions) == 0 {
+		return component.EmptyContentResponse, fmt.Errorf("crd %s has no no versions", crd.GetName())
+	}
+
+	crGVK, err := gvk.CustomResource(crd, crdVersions[0])
+	if err != nil {
+		return component.EmptyContentResponse, fmt.Errorf("get gvk for custom resource")
 	}
 
-	apiVersion, kind := gvk.ToAPIVersionAndKind()
+	apiVersion, kind := crGVK.ToAPIVersionAndKind()
 
 	key := store.Key{
 		Namespace:  namespace,
@@ -93,7 +104,7 @@ func (c *crd) Describe(ctx context.Context, namespace string, options Options) (
 
 	title := component.Title(
 		component.NewText("Custom Resources"),
-		component.NewText(crd.Name),
+		component.NewText(crd.GetName()),
 		component.NewText(object.GetName()))
 
 	iconName, iconSource := loadIcon(icon.CustomResourceDefinition)
diff --git a/internal/describer/crd_list.go b/internal/describer/crd_list.go
index e06441bc..e597df71 100644
--- a/internal/describer/crd_list.go
+++ b/internal/describer/crd_list.go
@@ -7,27 +7,16 @@ package describer
 
 import (
 	"context"
-	"fmt"
 
-	"github.com/pkg/errors"
-	apiextv1beta1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1beta1"
 	"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
-	"k8s.io/apimachinery/pkg/labels"
-	"k8s.io/apimachinery/pkg/runtime/schema"
 
 	"github.com/vmware-tanzu/octant/internal/link"
+	"github.com/vmware-tanzu/octant/internal/modules/overview/yamlviewer"
 	"github.com/vmware-tanzu/octant/internal/printer"
-	"github.com/vmware-tanzu/octant/pkg/icon"
-	"github.com/vmware-tanzu/octant/pkg/store"
 	"github.com/vmware-tanzu/octant/pkg/view/component"
 )
 
-type crdListPrinter func(
-	crdName string,
-	crd *apiextv1beta1.CustomResourceDefinition,
-	objects *unstructured.UnstructuredList,
-	linkGenerator link.Interface,
-	isLoading bool) (component.Component, error)
+type crdListPrinter func(crdObject *unstructured.Unstructured, resources *unstructured.UnstructuredList, version string, linkGenerator link.Interface) (component.Component, error)
 
 type crdListDescriptionOption func(*crdList)
 
@@ -57,63 +46,39 @@ func newCRDList(name, path string, options ...crdListDescriptionOption) *crdList
 
 func (cld *crdList) Describe(ctx context.Context, namespace string, options Options) (component.ContentResponse, error) {
 	objectStore := options.ObjectStore()
+
 	crd, err := CustomResourceDefinition(ctx, cld.name, objectStore)
 	if err != nil {
 		return component.EmptyContentResponse, err
 	}
 
-	objects, isLoading, err := ListCustomResources(ctx, crd, namespace, objectStore, options.LabelSet)
-	if err != nil {
-		return component.EmptyContentResponse, err
+	printOptions := printer.Options{
+		DashConfig: options.Dash,
+		Link:       options.Link,
 	}
 
-	table, err := cld.printer(cld.name, crd, objects, options.Link, isLoading)
+	view, err := printer.CustomResourceDefinitionHandler(ctx, crd, namespace, printOptions)
 	if err != nil {
 		return component.EmptyContentResponse, err
 	}
+	view.SetAccessor("summary")
 
-	list := component.NewList(fmt.Sprintf("Custom Resources / %s", cld.name), []component.Component{
-		table,
-	})
+	title := component.Title(
+		component.NewText("Custom Resources"),
+		component.NewText(crd.GetName()))
 
-	iconName, iconSource := loadIcon(icon.CustomResourceDefinition)
-	list.SetIcon(iconName, iconSource)
+	contentResponse := component.NewContentResponse(title)
+	contentResponse.Add(view)
 
-	return component.ContentResponse{
-		Components: []component.Component{list},
-	}, nil
-}
-
-func ListCustomResources(
-	ctx context.Context,
-	crd *apiextv1beta1.CustomResourceDefinition,
-	namespace string,
-	o store.Store,
-	selector *labels.Set) (*unstructured.UnstructuredList, bool, error) {
-	if crd == nil {
-		return nil, false, errors.New("crd is nil")
-	}
-	gvk := schema.GroupVersionKind{
-		Group:   crd.Spec.Group,
-		Version: crd.Spec.Version,
-		Kind:    crd.Spec.Names.Kind,
-	}
-
-	apiVersion, kind := gvk.ToAPIVersionAndKind()
-
-	key := store.Key{
-		Namespace:  namespace,
-		APIVersion: apiVersion,
-		Kind:       kind,
-		Selector:   selector,
-	}
-
-	objects, isLoading, err := o.List(ctx, key)
+	yamlView, err := yamlviewer.ToComponent(crd)
 	if err != nil {
-		return nil, false, errors.Wrapf(err, "listing custom resources for %q", crd.Name)
+		return component.EmptyContentResponse, err
 	}
+	yamlView.SetAccessor("yaml")
+
+	contentResponse.Add(yamlView)
 
-	return objects, isLoading, nil
+	return *contentResponse, nil
 }
 
 func (cld *crdList) PathFilters() []PathFilter {
diff --git a/internal/describer/crd_section.go b/internal/describer/crd_section.go
index 1d3a984a..ea99ef26 100644
--- a/internal/describer/crd_section.go
+++ b/internal/describer/crd_section.go
@@ -7,10 +7,14 @@ package describer
 
 import (
 	"context"
+	"path"
 	"sort"
 	"sync"
 
+	"github.com/vmware-tanzu/octant/internal/gvk"
 	"github.com/vmware-tanzu/octant/internal/log"
+	"github.com/vmware-tanzu/octant/internal/octant"
+	"github.com/vmware-tanzu/octant/pkg/store"
 	"github.com/vmware-tanzu/octant/pkg/view/component"
 )
 
@@ -57,28 +61,64 @@ func (csd *CRDSection) Describe(ctx context.Context, namespace string, options O
 
 	sort.Strings(names)
 
-	list := component.NewList("Custom Resources", nil)
+	tableCols := component.NewTableCols("Name", "Labels", "Age")
+	table := component.NewTable("Custom Resources", "", tableCols)
 
 	for _, name := range names {
-		resp, err := csd.describers[name].Describe(ctx, namespace, options)
-		if err != nil {
-			return component.EmptyContentResponse, err
-		}
+		switch d := csd.describers[name].(type) {
+		case *crdList:
+			key := store.KeyFromGroupVersionKind(gvk.CustomResourceDefinition)
+			key.Name = d.name
+			crd, _, err := options.ObjectStore().Get(ctx, key)
+			if err != nil {
+				return component.EmptyContentResponse, err
+			}
+
+			crdObject, err := octant.NewCustomResourceDefinition(crd)
+			if err != nil {
+				return component.EmptyContentResponse, err
+			}
+
+			versions, err := crdObject.Versions()
+			if err != nil {
+				return component.EmptyContentResponse, err
+			}
+
+			count := 0
+			for _, version := range versions {
+				crGVK, err := gvk.CustomResource(crd, version)
+				if err != nil {
+					return component.EmptyContentResponse, err
+				}
+				key2 := store.KeyFromGroupVersionKind(crGVK)
+				key2.Namespace = namespace
+				list, _, err := options.ObjectStore().List(ctx, key2)
+				if err != nil {
+					return component.EmptyContentResponse, err
+				}
+				count += len(list.Items)
+			}
+
+			if count > 0 {
+				row := component.TableRow{}
 
-		for i := range resp.Components {
-			if nestedList, ok := resp.Components[i].(*component.List); ok {
-				for i := range nestedList.Config.Items {
-					item := nestedList.Config.Items[i]
-					if !item.IsEmpty() {
-						list.Add(item)
-					}
+				ref := path.Join("/overview/namespace", namespace, "custom-resources", crd.GetName())
+				if namespace == "" {
+					ref = path.Join("/cluster-overview/custom-resources", crd.GetName())
 				}
+
+				row["Name"] = component.NewLink("", crd.GetName(), ref)
+				row["Labels"] = component.NewLabels(crd.GetLabels())
+				row["Age"] = component.NewTimestamp(crd.GetCreationTimestamp().Time)
+
+				table.Add(row)
 			}
+
 		}
 	}
 
 	cr := component.ContentResponse{
-		Components: []component.Component{list},
+		Components: []component.Component{table},
 		Title:      component.TitleFromString(csd.title),
 	}
 
diff --git a/internal/describer/customresource.go b/internal/describer/customresource.go
index f1defbfe..06326ceb 100644
--- a/internal/describer/customresource.go
+++ b/internal/describer/customresource.go
@@ -7,32 +7,25 @@ package describer
 
 import (
 	"context"
+	"fmt"
 	"path"
 
-	"github.com/pkg/errors"
-	apiextv1beta1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1beta1"
 	"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
 
+	"github.com/vmware-tanzu/octant/internal/gvk"
 	"github.com/vmware-tanzu/octant/internal/log"
 	"github.com/vmware-tanzu/octant/internal/module"
 	"github.com/vmware-tanzu/octant/internal/util/kubernetes"
 	"github.com/vmware-tanzu/octant/pkg/store"
 )
 
-func CustomResourceDefinition(ctx context.Context, name string, o store.Store) (*apiextv1beta1.CustomResourceDefinition, error) {
-	key := store.Key{
-		APIVersion: "apiextensions.k8s.io/v1beta1",
-		Kind:       "CustomResourceDefinition",
-		Name:       name,
-	}
+func CustomResourceDefinition(ctx context.Context, name string, o store.Store) (*unstructured.Unstructured, error) {
+	key := store.KeyFromGroupVersionKind(gvk.CustomResourceDefinition)
+	key.Name = name
 
-	crd := &apiextv1beta1.CustomResourceDefinition{}
-	found, err := store.GetAs(ctx, o, key, crd)
+	crd, _, err := o.Get(ctx, key)
 	if err != nil {
-		return nil, errors.Wrapf(err, "get object as custom resource definition %q from store", name)
-	}
-	if !found {
-		return nil, errors.Errorf("custom resource definition %q was not found", name)
+		return nil, fmt.Errorf("get %s: %w", key, err)
 	}
 
 	return crd, nil
@@ -46,6 +39,7 @@ func AddCRD(ctx context.Context, crd *unstructured.Unstructured, pm *PathMatcher
 
 	cld := newCRDList(name, crdListPath(name))
 
+	// TODO: this should add a list of custom resource definitions
 	crdSection.Add(name, cld)
 
 	for _, pf := range cld.PathFilters() {
diff --git a/internal/gvk/gvk.go b/internal/gvk/gvk.go
index d195c826..7c5d0782 100644
--- a/internal/gvk/gvk.go
+++ b/internal/gvk/gvk.go
@@ -6,6 +6,9 @@ SPDX-License-Identifier: Apache-2.0
 package gvk
 
 import (
+	"fmt"
+
+	"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
 	"k8s.io/apimachinery/pkg/runtime/schema"
 )
 
@@ -37,3 +40,33 @@ var (
 	RoleBinding              = schema.GroupVersionKind{Group: "rbac.authorization.k8s.io", Version: "v1", Kind: "RoleBinding"}
 	Role                     = schema.GroupVersionKind{Group: "rbac.authorization.k8s.io", Version: "v1", Kind: "Role"}
 )
+
+// CustomResource generates a `schema.GroupVersionKind` for a custom resource given a version.
+func CustomResource(crd *unstructured.Unstructured, version string) (schema.GroupVersionKind, error) {
+	if crd == nil {
+		return schema.GroupVersionKind{}, fmt.Errorf("custom resource definition is nil")
+	}
+
+	crdGroup := crd.GroupVersionKind().Group
+	crdKind := crd.GroupVersionKind().Kind
+
+	if crdGroup != CustomResourceDefinition.Group || crdKind != CustomResourceDefinition.Kind {
+		return schema.GroupVersionKind{}, fmt.Errorf("input was not a crd {group: %q, kind: %q}", crdGroup, crdKind)
+	}
+
+	group, _, err := unstructured.NestedString(crd.Object, "spec", "group")
+	if err != nil {
+		return schema.GroupVersionKind{}, fmt.Errorf("get crd group: %w", err)
+	}
+
+	kind, _, err := unstructured.NestedString(crd.Object, "spec", "names", "kind")
+	if err != nil {
+		return schema.GroupVersionKind{}, fmt.Errorf("get crd kind: %w", err)
+	}
+
+	return schema.GroupVersionKind{
+		Group:   group,
+		Version: version,
+		Kind:    kind,
+	}, nil
+}
diff --git a/internal/modules/overview/overview.go b/internal/modules/overview/overview.go
index 6de918a3..642deb95 100644
--- a/internal/modules/overview/overview.go
+++ b/internal/modules/overview/overview.go
@@ -136,7 +136,7 @@ func (co *Overview) bootstrap(ctx context.Context) error {
 	customResourcesDescriber := describer.NamespacedCRD()
 
 	watchConfig := &config.CRDWatchConfig{
-		Add: func(_ *describer.PathMatcher, sectionDescriber *describer.CRDSection) config.ObjectHandler {
+		Add: func(pathMatcher *describer.PathMatcher, sectionDescriber *describer.CRDSection) config.ObjectHandler {
 			return func(ctx context.Context, object *unstructured.Unstructured) {
 				co.mu.Lock()
 				defer co.mu.Unlock()
@@ -148,7 +148,7 @@ func (co *Overview) bootstrap(ctx context.Context) error {
 				co.watchedCRDs = append(co.watchedCRDs, object)
 			}
 		}(pathMatcher, customResourcesDescriber),
-		Delete: func(_ *describer.PathMatcher, csd *describer.CRDSection) config.ObjectHandler {
+		Delete: func(pathMatcher *describer.PathMatcher, csd *describer.CRDSection) config.ObjectHandler {
 			return func(ctx context.Context, object *unstructured.Unstructured) {
 				co.mu.Lock()
 				defer co.mu.Unlock()
diff --git a/internal/octant/customresource.go b/internal/octant/customresource.go
new file mode 100644
index 00000000..f3525f4a
--- /dev/null
+++ b/internal/octant/customresource.go
@@ -0,0 +1,224 @@
+/*
+ * Copyright (c) 2019 VMware, Inc. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package octant
+
+import (
+	"fmt"
+
+	"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
+)
+
+const (
+	crdAPIVersionV1beta1 = "apiextensions.k8s.io/v1beta1"
+	crdAPIVersionV1      = "apiextensions.k8s.io/v1"
+)
+
+type CustomResourceDefinitionPrinterColumn struct {
+	Name        string
+	Type        string
+	Description string
+	JSONPath    string
+}
+
+type CustomResourceDefinitionVersion struct {
+	Version        string
+	PrinterColumns []CustomResourceDefinitionPrinterColumn
+}
+
+type CustomResourceDefinition struct {
+	object *unstructured.Unstructured
+}
+
+func NewCustomResourceDefinition(object *unstructured.Unstructured) (*CustomResourceDefinition, error) {
+	if object == nil {
+		return nil, fmt.Errorf("object is nil")
+	}
+
+	crd := &CustomResourceDefinition{
+		object: object,
+	}
+
+	return crd, nil
+}
+
+func (crd *CustomResourceDefinition) Versions() ([]string, error) {
+	switch apiVersion := crd.object.GetAPIVersion(); apiVersion {
+	case crdAPIVersionV1:
+		return crd.versionNames()
+	case crdAPIVersionV1beta1:
+		// if .spec.version exists, return that version (old format)
+		version, found, err := unstructured.NestedString(crd.object.Object, "spec", "version")
+		if err != nil {
+			return nil, fmt.Errorf("unable to read crd .spec.version: %w", err)
+		}
+
+		if found {
+			return []string{version}, nil
+		}
+
+		return crd.versionNames()
+	default:
+		return nil, fmt.Errorf("crd with API version '%s' is not supported", apiVersion)
+	}
+}
+
+func (crd *CustomResourceDefinition) Version(version string) (CustomResourceDefinitionVersion, error) {
+	switch crd.object.GetAPIVersion() {
+	case crdAPIVersionV1:
+		return crd.v1Version(version)
+	case crdAPIVersionV1beta1:
+		return crd.v1beta1Version(version)
+	default:
+		return CustomResourceDefinitionVersion{}, fmt.Errorf("crd with API version '%s' is not supported", version)
+	}
+
+}
+
+func (crd *CustomResourceDefinition) v1Version(version string) (CustomResourceDefinitionVersion, error) {
+	versions, err := crd.versions()
+	if err != nil {
+		return CustomResourceDefinitionVersion{}, err
+	}
+	for i := range versions {
+		name, ok := versions[i]["name"].(string)
+		if !ok {
+			return CustomResourceDefinitionVersion{}, fmt.Errorf("unable to find CRD with version '%s'", version)
+		}
+
+		if name != version {
+			continue
+		}
+
+		columns, err := crdV1PrinterColumns(versions[i]["additionalPrinterColumns"])
+		if err != nil {
+			return CustomResourceDefinitionVersion{}, fmt.Errorf("collect CRD printer columns: %w", err)
+		}
+
+		customResourceDefinitionVersion := CustomResourceDefinitionVersion{
+			Version:        name,
+			PrinterColumns: columns,
+		}
+		return customResourceDefinitionVersion, nil
+	}
+
+	return CustomResourceDefinitionVersion{}, fmt.Errorf("unable to find version '%s'", version)
+}
+
+func (crd *CustomResourceDefinition) v1beta1Version(version string) (CustomResourceDefinitionVersion, error) {
+	raw, _, err := unstructured.NestedSlice(crd.object.Object, "spec", "additionalPrinterColumns")
+	if err != nil {
+		return CustomResourceDefinitionVersion{}, fmt.Errorf("unable to read crd .spec.additionalPrinterColumns: %w", err)
+	}
+
+	columns, err := crdV1beta1PrinterColumns(raw)
+	if err != nil {
+		return CustomResourceDefinitionVersion{}, fmt.Errorf("collect CRD printer columns: %w", err)
+	}
+
+	customResourceDefinitionVersion := CustomResourceDefinitionVersion{
+		Version:        version,
+		PrinterColumns: columns,
+	}
+	return customResourceDefinitionVersion, nil
+
+}
+
+func (crd *CustomResourceDefinition) versionNames() ([]string, error) {
+	objects, err := crd.versions()
+	if err != nil {
+		return nil, err
+	}
+
+	var versions []string
+	for i := range objects {
+		versions = append(versions, objects[i]["name"].(string))
+	}
+	return versions, nil
+}
+
+func (crd *CustomResourceDefinition) versions() ([]map[string]interface{}, error) {
+	versionsRaw, found, err := unstructured.NestedSlice(crd.object.Object, "spec", "versions")
+	if err != nil {
+		return nil, fmt.Errorf("unable to read crd .spec.versions: %w", err)
+	}
+
+	if !found {
+		return nil, nil
+	}
+
+	var versions []map[string]interface{}
+
+	for i := range versionsRaw {
+		versions = append(versions, versionsRaw[i].(map[string]interface{}))
+	}
+
+	return versions, nil
+}
+
+func crdV1PrinterColumns(in interface{}) ([]CustomResourceDefinitionPrinterColumn, error) {
+	if in == nil {
+		return []CustomResourceDefinitionPrinterColumn{}, nil
+	}
+
+	rawList, ok := in.([]interface{})
+	if !ok {
+		return nil, fmt.Errorf("unknown format for additional printer columns (%T)", in)
+	}
+
+	var columns []CustomResourceDefinitionPrinterColumn
+	for i := range rawList {
+		obj := rawList[i].(map[string]interface{})
+
+		column := CustomResourceDefinitionPrinterColumn{
+			Name:        mapString(obj, "name"),
+			Type:        mapString(obj, "type"),
+			Description: mapString(obj, "description"),
+			JSONPath:    mapString(obj, "jsonPath"),
+		}
+		columns = append(columns, column)
+	}
+
+	return columns, nil
+}
+
+func crdV1beta1PrinterColumns(in interface{}) ([]CustomResourceDefinitionPrinterColumn, error) {
+	if in == nil {
+		return []CustomResourceDefinitionPrinterColumn{}, nil
+	}
+
+	rawList, ok := in.([]interface{})
+	if !ok {
+		return nil, fmt.Errorf("unknown format for additional printer columns (%T)", in)
+	}
+
+	var columns []CustomResourceDefinitionPrinterColumn
+	for i := range rawList {
+		obj := rawList[i].(map[string]interface{})
+
+		column := CustomResourceDefinitionPrinterColumn{
+			Name:        mapString(obj, "name"),
+			Type:        mapString(obj, "type"),
+			Description: mapString(obj, "description"),
+			JSONPath:    mapString(obj, "JSONPath"),
+		}
+		columns = append(columns, column)
+	}
+
+	return columns, nil
+}
+
+func mapString(m map[string]interface{}, key string) string {
+	if m[key] == nil {
+		return ""
+	}
+
+	if s, ok := m[key].(string); ok {
+		return s
+	}
+
+	return ""
+
+}
diff --git a/internal/printer/customresource.go b/internal/printer/customresource.go
index e1177ab2..21f1c3cc 100644
--- a/internal/printer/customresource.go
+++ b/internal/printer/customresource.go
@@ -11,79 +11,55 @@ import (
 	"fmt"
 	"strings"
 
-	"github.com/pkg/errors"
-	apiextv1beta1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1beta1"
 	"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
 	"k8s.io/client-go/util/jsonpath"
 
 	"github.com/vmware-tanzu/octant/internal/link"
+	"github.com/vmware-tanzu/octant/internal/octant"
 	octantStrings "github.com/vmware-tanzu/octant/internal/util/strings"
 	"github.com/vmware-tanzu/octant/pkg/view/component"
 )
 
 // CustomResourceListHandler prints a list of custom resources with
 // optional custom columns.
-func CustomResourceListHandler(
-	crdName string,
-	crd *apiextv1beta1.CustomResourceDefinition,
-	list *unstructured.UnstructuredList,
-	linkGenerator link.Interface,
-	isLoading bool) (component.Component, error) {
-
-	hasCustomColumns := len(crd.Spec.AdditionalPrinterColumns) > 0
-	if hasCustomColumns {
-		return printCustomCRDListTable(crdName, crd, list, linkGenerator, isLoading)
+func CustomResourceListHandler(crdObject *unstructured.Unstructured, resources *unstructured.UnstructuredList, version string, linkGenerator link.Interface) (component.Component, error) {
+	if crdObject == nil {
+		return nil, fmt.Errorf("custom resource definition is nil")
 	}
 
-	return printGenericCRDTable(crdName, list, linkGenerator, isLoading)
-}
-
-func printGenericCRDTable(crdName string, list *unstructured.UnstructuredList, linkGenerator link.Interface, isLoading bool) (component.Component, error) {
-	cols := component.NewTableCols("Name", "Labels", "Age")
-	table := component.NewTable(crdName, "We couldn't find any custom resources!", cols)
+	tableName := fmt.Sprintf("%s/%s", crdObject.GetName(), version)
+	table := component.NewTable(tableName, "We couldn't find any custom resources!", component.NewTableCols("Name", "Labels"))
 
-	for i := range list.Items {
-		cr := list.Items[i]
-		row := component.TableRow{}
+	crd, err := octant.NewCustomResourceDefinition(crdObject)
+	if err != nil {
+		return nil, fmt.Errorf("create custom resource definition parse tool: %w", err)
+	}
 
-		name, err := linkGenerator.ForObject(&cr, cr.GetName())
+	if len(resources.Items) > 0 {
+		versionName := resources.Items[0].GroupVersionKind().Version
+		version, err := crd.Version(versionName)
 		if err != nil {
-			return nil, err
+			return nil, fmt.Errorf("get version '%s' from crd %s: %w", versionName, crdObject.GetName(), err)
 		}
 
-		row["Name"] = name
-		row["Labels"] = component.NewLabels(cr.GetLabels())
-		row["Age"] = component.NewTimestamp(cr.GetCreationTimestamp().Time)
-
-		table.Add(row)
-	}
-
-	table.SetIsLoading(isLoading)
-	table.Sort("Name", false)
-
-	return table, nil
-}
-
-func printCustomCRDListTable(
-	crdName string,
-	crd *apiextv1beta1.CustomResourceDefinition,
-	list *unstructured.UnstructuredList,
-	linkGenerator link.Interface,
-	isLoading bool) (component.Component, error) {
-
-	table := component.NewTable(crdName, "We couldn't find any custom resources!", component.NewTableCols("Name", "Labels"))
-	for _, column := range crd.Spec.AdditionalPrinterColumns {
-		name := column.Name
-		if octantStrings.Contains(column.Name, []string{"Name", "Labels", "Age"}) {
-			name = fmt.Sprintf("Resource %s", column.Name)
+		for _, column := range version.PrinterColumns {
+			name := column.Name
+			if octantStrings.Contains(column.Name, []string{"Name", "Labels", "Age"}) {
+				name = fmt.Sprintf("Resource %s", column.Name)
+			}
+			table.AddColumn(name)
 		}
-		table.AddColumn(name)
+		table.AddColumn("Age")
 	}
 
-	table.AddColumn("Age")
+	for i := range resources.Items {
+		versionName := resources.Items[i].GroupVersionKind().Version
+		version, err := crd.Version(versionName)
+		if err != nil {
+			return nil, fmt.Errorf("get version '%s' from crd '%s': %w", versionName, crdObject.GetName(), err)
+		}
 
-	for i := range list.Items {
-		cr := list.Items[i]
+		cr := resources.Items[i]
 		row := component.TableRow{}
 
 		name, err := linkGenerator.ForObject(&cr, cr.GetName())
@@ -95,11 +71,11 @@ func printCustomCRDListTable(
 		row["Labels"] = component.NewLabels(cr.GetLabels())
 		row["Age"] = component.NewTimestamp(cr.GetCreationTimestamp().Time)
 
-		for _, column := range crd.Spec.AdditionalPrinterColumns {
+		for _, column := range version.PrinterColumns {
 			s, err := printCustomColumn(cr.Object, column)
 			if err != nil {
-				return nil, errors.Wrapf(err, "print custom column %q in CRD %q",
-					column.Name, crd.Name)
+				return nil, fmt.Errorf("print custom column %q in CRD %q: %w",
+					column.Name, crdObject.GetName(), err)
 			}
 
 			name := column.Name
@@ -109,26 +85,24 @@ func printCustomCRDListTable(
 			}
 
 			row[name] = component.NewText(s)
-
 		}
 
 		table.Add(row)
 	}
 
-	table.SetIsLoading(isLoading)
 	table.Sort("Name", false)
 
 	return table, nil
 }
 
-func printCustomColumn(m interface{}, column apiextv1beta1.CustomResourceColumnDefinition) (string, error) {
+func printCustomColumn(m interface{}, column octant.CustomResourceDefinitionPrinterColumn) (string, error) {
 	j := jsonpath.New(column.Name)
 	buf := bytes.Buffer{}
 
 	s := strings.Replace(column.JSONPath, "\\", "", -1)
 
 	if err := j.Parse(fmt.Sprintf("{%s}", s)); err != nil {
-		return "", errors.Wrapf(err, "jsonpath parse error: %s", s)
+		return "", fmt.Errorf("json path parse error for '%s': %w", s, err)
 	}
 	if err := j.Execute(&buf, m); err != nil {
 		// inspecting the error string because jsonpath doesn't do typed errors
@@ -136,7 +110,7 @@ func printCustomColumn(m interface{}, column apiextv1beta1.CustomResourceColumnD
 			return "<not found>", nil
 		}
 
-		return "", errors.Wrapf(err, "jsonpath execute error")
+		return "", fmt.Errorf("json path execute error: %w", err)
 	}
 
 	return buf.String(), nil
@@ -144,59 +118,116 @@ func printCustomColumn(m interface{}, column apiextv1beta1.CustomResourceColumnD
 
 // CustomResourceHandler prints custom resource objects. If the
 // object has columns specified, it will print those columns as well.
-func CustomResourceHandler(
-	ctx context.Context,
-	crd *apiextv1beta1.CustomResourceDefinition,
-	object *unstructured.Unstructured,
-	options Options) (component.Component, error) {
-	o := NewObject(object)
-
-	configSummary, err := printCustomResourceConfig(object, crd)
+func CustomResourceHandler(ctx context.Context, crd, cr *unstructured.Unstructured, options Options) (component.Component, error) {
+	object := NewObject(cr)
+	object.EnableEvents()
+
+	h, err := newCustomResourceHandler(crd, cr, object)
 	if err != nil {
 		return nil, err
 	}
 
-	statusSummary, err := printCustomResourceStatus(object, crd)
-	if err != nil {
-		return nil, err
+	if err := h.Config(); err != nil {
+		return nil, fmt.Errorf("print custom resource configuration: %w", err)
 	}
 
-	o.RegisterConfig(configSummary)
-	o.RegisterSummary(statusSummary)
-	o.EnableEvents()
+	if err := h.Status(); err != nil {
+		return nil, fmt.Errorf("print custom resource status: %w", err)
+	}
 
-	view, err := o.ToComponent(ctx, options)
+	view, err := object.ToComponent(ctx, options)
 	if err != nil {
-		return nil, errors.Wrap(err, "print custom resource")
+		return nil, fmt.Errorf("print custom resource: %w", err)
 	}
 
 	return view, nil
 }
 
-func printCustomResourceConfig(u *unstructured.Unstructured, crd *apiextv1beta1.CustomResourceDefinition) (*component.Summary, error) {
+type customResourceObject interface {
+	Config() error
+	Status() error
+}
+
+type customResourceHandler struct {
+	statusFunc func(crd, cr *unstructured.Unstructured) (*component.Summary, error)
+	configFunc func(crd, cr *unstructured.Unstructured) (*component.Summary, error)
+	crd        *unstructured.Unstructured
+	cr         *unstructured.Unstructured
+	object     *Object
+}
+
+var _ customResourceObject = (*customResourceHandler)(nil)
+
+func newCustomResourceHandler(crd, u *unstructured.Unstructured, object *Object) (*customResourceHandler, error) {
 	if crd == nil {
-		return nil, errors.New("CRD is nil")
+		return nil, fmt.Errorf("custom resource definition is nil")
 	}
 
-	summary := component.NewSummary("Configuration")
+	if u == nil {
+		return nil, fmt.Errorf("custom resource is nil")
+	}
+
+	if object == nil {
+		return nil, fmt.Errorf("can't print custom resource using a nil object printer")
+	}
+
+	h := &customResourceHandler{
+		crd:        crd,
+		cr:         u,
+		statusFunc: printCustomResourceStatus,
+		configFunc: printCustomResourceConfig,
+		object:     object,
+	}
+
+	return h, nil
+}
+
+func (c *customResourceHandler) Config() error {
+	out, err := c.configFunc(c.crd, c.cr)
+	if err != nil {
+		return err
+	}
+	c.object.RegisterConfig(out)
+	return nil
+}
 
-	if len(crd.Spec.AdditionalPrinterColumns) < 1 {
-		return summary, nil
+func printCustomResourceConfig(crd, cr *unstructured.Unstructured) (*component.Summary, error) {
+	return printCustomResourceSummaryWithPrefix(crd, cr, "Configuration", ".spec")
+}
+
+func (c *customResourceHandler) Status() error {
+	out, err := c.statusFunc(c.crd, c.cr)
+	if err != nil {
+		return err
 	}
+	c.object.RegisterSummary(out)
+	return nil
+}
+
+func printCustomResourceStatus(crd, cr *unstructured.Unstructured) (*component.Summary, error) {
+	return printCustomResourceSummaryWithPrefix(crd, cr, "Status", ".status")
+}
+
+func printCustomResourceSummaryWithPrefix(crd, cr *unstructured.Unstructured, title, prefix string) (*component.Summary, error) {
+	crdVersion, err := crdVersion(crd, cr)
+	if err != nil {
+		return nil, fmt.Errorf("fetch crd version: %w", err)
+	}
+
+	summary := component.NewSummary(title)
 
 	sections := component.SummarySections{}
 
-	for _, column := range crd.Spec.AdditionalPrinterColumns {
-		if strings.HasPrefix(column.JSONPath, ".spec") {
-			s, err := printCustomColumn(u.Object, column)
+	for _, column := range crdVersion.PrinterColumns {
+		if strings.HasPrefix(column.JSONPath, prefix) {
+			s, err := printCustomColumn(cr.Object, column)
 			if err != nil {
-				return nil, errors.Wrap(err, "print custom column")
+				return nil, fmt.Errorf("print custom column '%s': %w", column.Name, err)
 			}
 
 			if s != "" {
 				sections.AddText(column.Name, s)
 			}
-
 		}
 	}
 
@@ -205,31 +236,24 @@ func printCustomResourceConfig(u *unstructured.Unstructured, crd *apiextv1beta1.
 	return summary, nil
 }
 
-func printCustomResourceStatus(u *unstructured.Unstructured, crd *apiextv1beta1.CustomResourceDefinition) (*component.Summary, error) {
+func crdVersion(crd, cr *unstructured.Unstructured) (octant.CustomResourceDefinitionVersion, error) {
 	if crd == nil {
-		return nil, errors.New("CRD is nil")
+		return octant.CustomResourceDefinitionVersion{}, fmt.Errorf("custom resource definition is nil")
 	}
 
-	summary := component.NewSummary("Status")
-
-	if len(crd.Spec.AdditionalPrinterColumns) < 1 {
-		return summary, nil
+	if cr == nil {
+		return octant.CustomResourceDefinitionVersion{}, fmt.Errorf("custom resource is nil")
 	}
 
-	sections := component.SummarySections{}
-
-	for _, column := range crd.Spec.AdditionalPrinterColumns {
-		if strings.HasPrefix(column.JSONPath, ".status") {
-			s, err := printCustomColumn(u.Object, column)
-			if err != nil {
-				return nil, errors.Wrap(err, "print custom column")
-			}
-
-			sections.AddText(column.Name, s)
-		}
+	octantCRD, err := octant.NewCustomResourceDefinition(crd)
+	if err != nil {
+		return octant.CustomResourceDefinitionVersion{}, fmt.Errorf("create octant CRD: %w", err)
 	}
 
-	summary.Add(sections...)
+	crdVersion, err := octantCRD.Version(cr.GroupVersionKind().Version)
+	if err != nil {
+		return octant.CustomResourceDefinitionVersion{}, fmt.Errorf("get version for custom resource: %w", err)
+	}
 
-	return summary, nil
+	return crdVersion, nil
 }
diff --git a/internal/printer/customresourcedefinition.go b/internal/printer/customresourcedefinition.go
new file mode 100644
index 00000000..fe0e8f49
--- /dev/null
+++ b/internal/printer/customresourcedefinition.go
@@ -0,0 +1,107 @@
+/*
+ * Copyright (c) 2019 VMware, Inc. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package printer
+
+import (
+	"context"
+	"fmt"
+
+	"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
+
+	"github.com/vmware-tanzu/octant/internal/gvk"
+	"github.com/vmware-tanzu/octant/internal/octant"
+	"github.com/vmware-tanzu/octant/pkg/store"
+	"github.com/vmware-tanzu/octant/pkg/view/component"
+)
+
+func CustomResourceDefinitionHandler(ctx context.Context, crd *unstructured.Unstructured, namespace string, options Options) (component.Component, error) {
+	object := NewObject(crd)
+	object.EnableEvents()
+
+	config, err := printCustomResourceDefinitionConfig(crd)
+	if err != nil {
+		return nil, err
+	}
+	object.RegisterConfig(config)
+
+	octantCRD, err := octant.NewCustomResourceDefinition(crd)
+	if err != nil {
+		return nil, err
+	}
+
+	objectStore := options.DashConfig.ObjectStore()
+
+	versions, err := octantCRD.Versions()
+	if err != nil {
+		return nil, err
+	}
+
+	for _, version := range versions {
+		object.RegisterItems(ItemDescriptor{
+			Func: func() (c component.Component, err error) {
+				crGVK, err := gvk.CustomResource(crd, version)
+				if err != nil {
+					return nil, err
+				}
+
+				key := store.KeyFromGroupVersionKind(crGVK)
+				key.Namespace = namespace
+
+				customResources, _, err := objectStore.List(ctx, key)
+				if err != nil {
+					return nil, err
+				}
+
+				return CustomResourceListHandler(crd, customResources, version, options.Link)
+			},
+			Width: component.WidthFull,
+		})
+
+	}
+
+	view, err := object.ToComponent(ctx, options)
+	if err != nil {
+		return nil, fmt.Errorf("print custom resource definition: %w", err)
+	}
+
+	return view, nil
+}
+
+func printCustomResourceDefinitionConfig(crd *unstructured.Unstructured) (*component.Summary, error) {
+	if crd == nil {
+		return nil, fmt.Errorf("custom resource definition is nil")
+	}
+
+	summary := component.NewSummary("Config")
+
+	group, err := nestedString(crd, "spec", "group")
+	if err != nil {
+		return nil, err
+	}
+
+	kind, err := nestedString(crd, "spec", "names", "kind")
+	if err != nil {
+		return nil, err
+	}
+
+	summary.AddSection("Group", component.NewText(group))
+	summary.AddSection("Kind", component.NewText(kind))
+
+	return summary, nil
+}
+
+func nestedString(object *unstructured.Unstructured, fields ...string) (string, error) {
+	s, found, err := unstructured.NestedString(object.Object, fields...)
+	if err != nil {
+		return "", err
+	}
+
+	if !found {
+		return "", nil
+	}
+
+	return s, nil
+}
diff --git a/pkg/view/component/summary.go b/pkg/view/component/summary.go
index 0ed92201..532a6560 100644
--- a/pkg/view/component/summary.go
+++ b/pkg/view/component/summary.go
@@ -75,11 +75,6 @@ func NewSummary(title string, sections ...SummarySection) *Summary {
 	}
 }
 
-// GetMetadata accesses the components metadata. Implements Component.
-func (t *Summary) GetMetadata() Metadata {
-	return t.Metadata
-}
-
 func (t *Summary) AddAction(action Action) {
 	t.Config.Actions = append(t.Config.Actions, action)
 }
@@ -89,6 +84,14 @@ func (t *Summary) Add(sections ...SummarySection) {
 	t.Config.Sections = append(t.Config.Sections, sections...)
 }
 
+// AddSection adds a section to the tail of a summary.
+func (t *Summary) AddSection(header string, content Component) {
+	t.Add(SummarySection{
+		Header:  header,
+		Content: content,
+	})
+}
+
 // SetAlert sets an alert for the summary.
 func (t *Summary) SetAlert(alert Alert) {
 	t.Config.Alert = &alert


Test patch:
diff --git a/internal/describer/crd_list_test.go b/internal/describer/crd_list_test.go
deleted file mode 100644
index a2533523..00000000
--- a/internal/describer/crd_list_test.go
+++ /dev/null
@@ -1,82 +0,0 @@
-/*
-Copyright (c) 2019 the Octant contributors. All Rights Reserved.
-SPDX-License-Identifier: Apache-2.0
-*/
-
-package describer
-
-import (
-	"context"
-	"testing"
-
-	"github.com/golang/mock/gomock"
-	"github.com/stretchr/testify/require"
-	apiextv1beta1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1beta1"
-	"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
-
-	configFake "github.com/vmware-tanzu/octant/internal/config/fake"
-	"github.com/vmware-tanzu/octant/internal/link"
-	"github.com/vmware-tanzu/octant/internal/testutil"
-	"github.com/vmware-tanzu/octant/pkg/icon"
-	"github.com/vmware-tanzu/octant/pkg/store"
-	storefake "github.com/vmware-tanzu/octant/pkg/store/fake"
-	"github.com/vmware-tanzu/octant/pkg/view/component"
-)
-
-func Test_crdListDescriber(t *testing.T) {
-	controller := gomock.NewController(t)
-	defer controller.Finish()
-
-	o := storefake.NewMockStore(controller)
-
-	crd := testutil.CreateCRD("crd1")
-	crd.Spec.Group = "foo.example.com"
-	crd.Spec.Version = "v1"
-	crd.Spec.Names.Kind = "Name"
-
-	crdKey := store.Key{
-		APIVersion: "apiextensions.k8s.io/v1beta1",
-		Kind:       "CustomResourceDefinition",
-		Name:       crd.Name,
-	}
-
-	o.EXPECT().Get(gomock.Any(), gomock.Eq(crdKey)).Return(testutil.ToUnstructured(t, crd), true, nil)
-
-	crKey := store.Key{
-		Namespace:  "default",
-		APIVersion: "foo.example.com/v1",
-		Kind:       "Name",
-	}
-
-	objects := &unstructured.UnstructuredList{}
-	o.EXPECT().List(gomock.Any(), gomock.Eq(crKey)).Return(objects, false, nil)
-
-	listPrinter := func(cld *crdList) {
-		cld.printer = func(name string, crd *apiextv1beta1.CustomResourceDefinition, objects *unstructured.UnstructuredList, linkGenerator link.Interface, loading bool) (component.Component, error) {
-			return component.NewText("crd list"), nil
-		}
-	}
-
-	dashConfig := configFake.NewMockDash(controller)
-	dashConfig.EXPECT().ObjectStore().Return(o).AnyTimes()
-
-	options := Options{
-		Dash: dashConfig,
-	}
-	cld := newCRDList(crd.Name, "path", listPrinter)
-
-	ctx := context.Background()
-
-	got, err := cld.Describe(ctx, "default", options)
-	require.NoError(t, err)
-
-	expected := *component.NewContentResponse(nil)
-	list := component.NewList("Custom Resources / crd1", []component.Component{
-		component.NewText("crd list"),
-	})
-	iconName, iconSource := loadIcon(icon.CustomResourceDefinition)
-	list.SetIcon(iconName, iconSource)
-	expected.Add(list)
-
-	testutil.AssertJSONEqual(t, expected, got)
-}
diff --git a/internal/describer/crd_section_test.go b/internal/describer/crd_section_test.go
deleted file mode 100644
index 1fab6f25..00000000
--- a/internal/describer/crd_section_test.go
+++ /dev/null
@@ -1,68 +0,0 @@
-/*
-Copyright (c) 2019 the Octant contributors. All Rights Reserved.
-SPDX-License-Identifier: Apache-2.0
-*/
-
-package describer
-
-import (
-	"context"
-	"testing"
-
-	"github.com/stretchr/testify/assert"
-	"github.com/stretchr/testify/require"
-
-	"github.com/vmware-tanzu/octant/pkg/view/component"
-)
-
-func Test_crdSectionDescriber(t *testing.T) {
-	csd := NewCRDSection("/path", "title")
-
-	d1View := component.NewText("d1")
-	d1 := NewStubDescriber("/d1", component.NewList("", []component.Component{d1View}))
-
-	csd.Add("d1", d1)
-
-	ctx := context.Background()
-
-	view1, err := csd.Describe(ctx, "default", Options{})
-	require.NoError(t, err)
-
-	expect1 := component.ContentResponse{
-		Title: component.TitleFromString("title"),
-		Components: []component.Component{
-			component.NewList("Custom Resources", []component.Component{d1View}),
-		},
-	}
-
-	assert.Equal(t, expect1, view1)
-
-	csd.Remove("d1")
-
-	view2, err := csd.Describe(ctx, "default", Options{})
-	require.NoError(t, err)
-
-	expect2 := component.ContentResponse{
-		Title: component.TitleFromString("title"),
-		Components: []component.Component{
-			component.NewList("Custom Resources", nil),
-		},
-	}
-
-	assert.Equal(t, expect2, view2)
-
-	csd.Add("d1", d1)
-	require.NoError(t, csd.Reset(ctx))
-
-	view3, err := csd.Describe(ctx, "default", Options{})
-	require.NoError(t, err)
-
-	expected3 := component.ContentResponse{
-		Title: component.TitleFromString("title"),
-		Components: []component.Component{
-			component.NewList("Custom Resources", nil),
-		},
-	}
-
-	assert.Equal(t, expected3, view3)
-}
diff --git a/internal/describer/crd_test.go b/internal/describer/crd_test.go
deleted file mode 100644
index 81ebc20e..00000000
--- a/internal/describer/crd_test.go
+++ /dev/null
@@ -1,117 +0,0 @@
-/*
-Copyright (c) 2019 the Octant contributors. All Rights Reserved.
-SPDX-License-Identifier: Apache-2.0
-*/
-
-package describer
-
-import (
-	"context"
-	"testing"
-
-	"github.com/golang/mock/gomock"
-	"github.com/stretchr/testify/require"
-	apiextv1beta1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1beta1"
-	"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
-	"k8s.io/apimachinery/pkg/runtime"
-
-	"github.com/vmware-tanzu/octant/internal/config"
-	configFake "github.com/vmware-tanzu/octant/internal/config/fake"
-	linkFake "github.com/vmware-tanzu/octant/internal/link/fake"
-	"github.com/vmware-tanzu/octant/internal/printer"
-	"github.com/vmware-tanzu/octant/internal/queryer"
-	"github.com/vmware-tanzu/octant/internal/testutil"
-	"github.com/vmware-tanzu/octant/pkg/icon"
-	pluginFake "github.com/vmware-tanzu/octant/pkg/plugin/fake"
-	"github.com/vmware-tanzu/octant/pkg/store"
-	storefake "github.com/vmware-tanzu/octant/pkg/store/fake"
-	"github.com/vmware-tanzu/octant/pkg/view/component"
-)
-
-func Test_crd(t *testing.T) {
-	ctx := context.Background()
-
-	controller := gomock.NewController(t)
-	defer controller.Finish()
-
-	dashConfig := configFake.NewMockDash(controller)
-
-	objectStore := storefake.NewMockStore(controller)
-	dashConfig.EXPECT().ObjectStore().Return(objectStore)
-
-	crdObject := testutil.CreateCRD("crd1")
-	crdObject.Spec.Group = "foo.example.com"
-	crdObject.Spec.Version = "v1"
-	crdObject.Spec.Names.Kind = "Name"
-
-	crdKey := store.Key{
-		APIVersion: "apiextensions.k8s.io/v1beta1",
-		Kind:       "CustomResourceDefinition",
-		Name:       crdObject.Name,
-	}
-
-	objectStore.EXPECT().Get(gomock.Any(), gomock.Eq(crdKey)).Return(testutil.ToUnstructured(t, crdObject), true, nil)
-
-	crKey := store.Key{
-		Namespace:  "default",
-		APIVersion: "foo.example.com/v1",
-		Kind:       "Name",
-	}
-
-	object := testutil.CreateCustomResource("cr")
-	objectStore.EXPECT().Get(gomock.Any(), gomock.Eq(crKey)).Return(object, true, nil)
-
-	linkGenerator := linkFake.NewMockInterface(controller)
-
-	pluginManager := pluginFake.NewMockManagerInterface(controller)
-	dashConfig.EXPECT().PluginManager().Return(pluginManager)
-
-	var tabs []component.Tab
-	pluginManager.EXPECT().Tabs(gomock.Any(), object).Return(tabs, nil)
-
-	crPrinter := func(cd *crd) {
-		cd.summaryPrinter = func(ctx context.Context, crd *apiextv1beta1.CustomResourceDefinition, object *unstructured.Unstructured, options printer.Options) (component.Component, error) {
-			return component.NewText("cr"), nil
-		}
-
-		cd.resourceViewerPrinter = func(ctx context.Context, object *unstructured.Unstructured, dashConfig config.Dash, q queryer.Queryer) (component.Component, error) {
-			return component.NewText("rv"), nil
-		}
-
-		cd.yamlPrinter = func(runtime.Object) (*component.YAML, error) {
-			return component.NewYAML(component.TitleFromString("yaml"), "data"), nil
-		}
-	}
-
-	c := newCRD(crdObject.Name, "path", crPrinter)
-
-	options := Options{
-		Dash: dashConfig,
-		Link: linkGenerator,
-	}
-
-	got, err := c.Describe(ctx, "default", options)
-	require.NoError(t, err)
-
-	expected := *component.NewContentResponse([]component.TitleComponent{
-		component.NewText("Custom Resources"),
-		component.NewText("crd1"),
-		component.NewText("cr"),
-	})
-
-	iconName, iconSource := loadIcon(icon.CustomResourceDefinition)
-	expected.IconName = iconName
-	expected.IconSource = iconSource
-
-	crView := component.NewText("cr")
-	crView.SetAccessor("summary")
-	expected.Add(crView)
-	rvView := component.NewText("rv")
-	rvView.SetAccessor("resourceViewer")
-	expected.Add(rvView)
-	yView := component.NewYAML(component.TitleFromString("yaml"), "data")
-	yView.SetAccessor("yaml")
-	expected.Add(yView)
-
-	testutil.AssertJSONEqual(t, expected, got)
-}
diff --git a/internal/describer/customresource_test.go b/internal/describer/customresource_test.go
deleted file mode 100644
index d656bd0f..00000000
--- a/internal/describer/customresource_test.go
+++ /dev/null
@@ -1,42 +0,0 @@
-/*
-Copyright (c) 2019 the Octant contributors. All Rights Reserved.
-SPDX-License-Identifier: Apache-2.0
-*/
-
-package describer
-
-import (
-	"context"
-	"testing"
-
-	"github.com/golang/mock/gomock"
-	"github.com/stretchr/testify/assert"
-	"github.com/stretchr/testify/require"
-
-	"github.com/vmware-tanzu/octant/internal/testutil"
-	"github.com/vmware-tanzu/octant/pkg/store"
-	storeFake "github.com/vmware-tanzu/octant/pkg/store/fake"
-)
-
-func Test_customResourceDefinition(t *testing.T) {
-	controller := gomock.NewController(t)
-	defer controller.Finish()
-
-	o := storeFake.NewMockStore(controller)
-
-	crd1 := testutil.CreateCRD("crd1.example.com")
-
-	crdKey := store.Key{
-		APIVersion: "apiextensions.k8s.io/v1beta1",
-		Kind:       "CustomResourceDefinition",
-		Name:       "crd1.example.com",
-	}
-	o.EXPECT().Get(gomock.Any(), gomock.Eq(crdKey)).Return(testutil.ToUnstructured(t, crd1), true, nil)
-
-	name := "crd1.example.com"
-	ctx := context.Background()
-	got, err := CustomResourceDefinition(ctx, name, o)
-	require.NoError(t, err)
-
-	assert.Equal(t, crd1, got)
-}
diff --git a/internal/octant/customresource_test.go b/internal/octant/customresource_test.go
new file mode 100644
index 00000000..d9daf434
--- /dev/null
+++ b/internal/octant/customresource_test.go
@@ -0,0 +1,158 @@
+/*
+ * Copyright (c) 2019 VMware, Inc. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package octant_test
+
+import (
+	"testing"
+
+	"github.com/stretchr/testify/require"
+	"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
+
+	"github.com/vmware-tanzu/octant/internal/octant"
+	"github.com/vmware-tanzu/octant/internal/testutil"
+)
+
+func TestNewCustomResourceDefinition(t *testing.T) {
+	tests := []struct {
+		name    string
+		object  *unstructured.Unstructured
+		wantErr bool
+	}{
+		{
+			name:   "with an object",
+			object: testutil.LoadUnstructuredFromFile(t, "crd-v1.yaml"),
+		},
+		{
+			name:    "without object",
+			wantErr: true,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			_, err := octant.NewCustomResourceDefinition(tt.object)
+			testutil.RequireErrorOrNot(t, tt.wantErr, err)
+		})
+	}
+}
+
+func TestCustomResourceDefinition_Versions(t *testing.T) {
+	tests := []struct {
+		name    string
+		object  *unstructured.Unstructured
+		want    []string
+		wantErr bool
+	}{
+		{
+			name:   "v1",
+			object: testutil.LoadUnstructuredFromFile(t, "crd-v1.yaml"),
+			want:   []string{"v1"},
+		},
+		{
+			name:   "v1beta1",
+			object: testutil.LoadUnstructuredFromFile(t, "crd-v1beta1.yaml"),
+			want:   []string{"v1"},
+		},
+		{
+			name:   "v1beta1 - versions",
+			object: testutil.LoadUnstructuredFromFile(t, "crd-v1beta1-versions.yaml"),
+			want:   []string{"v1"},
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			crd, err := octant.NewCustomResourceDefinition(tt.object)
+			require.NoError(t, err)
+
+			got, err := crd.Versions()
+			testutil.RequireErrorOrNot(t, tt.wantErr, err)
+			require.Equal(t, tt.want, got)
+		})
+	}
+}
+
+func TestCustomResourceDefinition_Version(t *testing.T) {
+	tests := []struct {
+		name    string
+		object  *unstructured.Unstructured
+		version string
+		want    octant.CustomResourceDefinitionVersion
+		wantErr bool
+	}{
+		{
+			name:    "v1",
+			object:  testutil.LoadUnstructuredFromFile(t, "crd-v1.yaml"),
+			version: "v1",
+			want: octant.CustomResourceDefinitionVersion{
+				Version: "v1",
+				PrinterColumns: []octant.CustomResourceDefinitionPrinterColumn{
+					{
+						Name:        "Spec",
+						Type:        "string",
+						Description: "The cron spec defining the interval a CronJob is run",
+						JSONPath:    ".spec.cronSpec",
+					},
+					{
+						Name:        "Replicas",
+						Type:        "integer",
+						Description: "The number of jobs launched by the CronJob",
+						JSONPath:    ".spec.replicas",
+					},
+					{
+						Name:     "Age",
+						Type:     "date",
+						JSONPath: ".metadata.creationTimestamp",
+					},
+				},
+			},
+		},
+		{
+			name:   "v1beta1",
+			object: testutil.LoadUnstructuredFromFile(t, "crd-v1beta1.yaml"),
+			want: octant.CustomResourceDefinitionVersion{
+				Version: "v1",
+				PrinterColumns: []octant.CustomResourceDefinitionPrinterColumn{
+					{
+						Name:        "Spec",
+						Type:        "string",
+						Description: "The cron spec defining the interval a CronJob is run",
+						JSONPath:    ".spec.cronSpec",
+					},
+					{
+						Name:        "Replicas",
+						Type:        "integer",
+						Description: "The number of jobs launched by the CronJob",
+						JSONPath:    ".spec.replicas",
+					},
+					{
+						Name:     "Age",
+						Type:     "date",
+						JSONPath: ".metadata.creationTimestamp",
+					},
+				},
+			},
+		},
+		{
+			name:   "v1beta1 no columns",
+			object: testutil.LoadUnstructuredFromFile(t, "crd-v1beta1-versions.yaml"),
+			want: octant.CustomResourceDefinitionVersion{
+				Version: "v1",
+			},
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			crd, err := octant.NewCustomResourceDefinition(tt.object)
+			require.NoError(t, err)
+
+			got, err := crd.Version("v1")
+			testutil.RequireErrorOrNot(t, tt.wantErr, err)
+			require.Equal(t, tt.want, got)
+		})
+	}
+}
diff --git a/internal/octant/testdata/crd-v1.yaml b/internal/octant/testdata/crd-v1.yaml
new file mode 100644
index 00000000..b100ccb8
--- /dev/null
+++ b/internal/octant/testdata/crd-v1.yaml
@@ -0,0 +1,42 @@
+apiVersion: apiextensions.k8s.io/v1
+kind: CustomResourceDefinition
+metadata:
+  name: crontabs.stable.example.com
+spec:
+  group: stable.example.com
+  scope: Namespaced
+  names:
+    plural: crontabs
+    singular: crontab
+    kind: CronTab
+    shortNames:
+      - ct
+  versions:
+    - name: v1
+      served: true
+      storage: true
+      schema:
+        openAPIV3Schema:
+          type: object
+          properties:
+            spec:
+              type: object
+              properties:
+                cronSpec:
+                  type: string
+                image:
+                  type: string
+                replicas:
+                  type: integer
+      additionalPrinterColumns:
+        - name: Spec
+          type: string
+          description: The cron spec defining the interval a CronJob is run
+          jsonPath: .spec.cronSpec
+        - name: Replicas
+          type: integer
+          description: The number of jobs launched by the CronJob
+          jsonPath: .spec.replicas
+        - name: Age
+          type: date
+          jsonPath: .metadata.creationTimestamp
diff --git a/internal/octant/testdata/crd-v1beta1-versions.yaml b/internal/octant/testdata/crd-v1beta1-versions.yaml
new file mode 100644
index 00000000..5a507ab4
--- /dev/null
+++ b/internal/octant/testdata/crd-v1beta1-versions.yaml
@@ -0,0 +1,17 @@
+apiVersion: apiextensions.k8s.io/v1beta1
+kind: CustomResourceDefinition
+metadata:
+  name: crontabs.stable.example.com
+spec:
+  group: stable.example.com
+  versions:
+    - name: v1
+      served: true
+      storage: true
+  scope: Namespaced
+  names:
+    plural: crontabs
+    singular: crontab
+    kind: CronTab
+    shortNames:
+      - ct
diff --git a/internal/octant/testdata/crd-v1beta1.yaml b/internal/octant/testdata/crd-v1beta1.yaml
new file mode 100644
index 00000000..1a468455
--- /dev/null
+++ b/internal/octant/testdata/crd-v1beta1.yaml
@@ -0,0 +1,40 @@
+# Deprecated in v1.16 in favor of apiextensions.k8s.io/v1
+apiVersion: apiextensions.k8s.io/v1beta1
+kind: CustomResourceDefinition
+metadata:
+  name: crontabs.stable.example.com
+spec:
+  group: stable.example.com
+  version: v1
+  scope: Namespaced
+  names:
+    plural: crontabs
+    singular: crontab
+    kind: CronTab
+    shortNames:
+      - ct
+  validation:
+    openAPIV3Schema:
+      type: object
+      properties:
+        spec:
+          type: object
+          properties:
+            cronSpec:
+              type: string
+            image:
+              type: string
+            replicas:
+              type: integer
+  additionalPrinterColumns:
+    - name: Spec
+      type: string
+      description: The cron spec defining the interval a CronJob is run
+      JSONPath: .spec.cronSpec
+    - name: Replicas
+      type: integer
+      description: The number of jobs launched by the CronJob
+      JSONPath: .spec.replicas
+    - name: Age
+      type: date
+      JSONPath: .metadata.creationTimestamp
diff --git a/internal/printer/customresource_test.go b/internal/printer/customresource_test.go
index e2aeef05..85c4f7f0 100644
--- a/internal/printer/customresource_test.go
+++ b/internal/printer/customresource_test.go
@@ -6,19 +6,15 @@ SPDX-License-Identifier: Apache-2.0
 package printer
 
 import (
-	"os"
-	"path/filepath"
 	"testing"
 	"time"
 
 	"github.com/golang/mock/gomock"
 	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/require"
-	apiextv1beta1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1beta1"
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
-	"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
-	"k8s.io/apimachinery/pkg/util/yaml"
 
+	"github.com/vmware-tanzu/octant/internal/octant"
 	"github.com/vmware-tanzu/octant/internal/testutil"
 	"github.com/vmware-tanzu/octant/pkg/view/component"
 )
@@ -29,8 +25,8 @@ func Test_CustomResourceListHandler(t *testing.T) {
 
 	tpo := newTestPrinterOptions(controller)
 
-	crd := loadCRDFromFile(t, "crd.yaml")
-	resource := loadCRFromFile(t, "crd-resource.yaml")
+	crd := testutil.LoadUnstructuredFromFile(t, "crd.yaml")
+	resource := testutil.LoadUnstructuredFromFile(t, "crd-resource.yaml")
 
 	now := time.Now()
 	resource.SetCreationTimestamp(metav1.Time{Time: now})
@@ -41,11 +37,11 @@ func Test_CustomResourceListHandler(t *testing.T) {
 	resource.SetLabels(labels)
 
 	list := testutil.ToUnstructuredList(t, resource)
-	got, err := CustomResourceListHandler(crd.Name, crd, list, tpo.link, true)
+	got, err := CustomResourceListHandler(crd, list, "v1", tpo.link)
 	require.NoError(t, err)
 
 	expected := component.NewTableWithRows(
-		"crontabs.stable.example.com", "We couldn't find any custom resources!",
+		"crontabs.stable.example.com/v1", "We couldn't find any custom resources!",
 		component.NewTableCols("Name", "Labels", "Age"),
 		[]component.TableRow{
 			{
@@ -54,7 +50,6 @@ func Test_CustomResourceListHandler(t *testing.T) {
 				"Labels": component.NewLabels(labels),
 			},
 		})
-	expected.SetIsLoading(true)
 
 	component.AssertEqual(t, expected, got)
 }
@@ -65,8 +60,8 @@ func Test_CustomResourceListHandler_custom_columns(t *testing.T) {
 
 	tpo := newTestPrinterOptions(controller)
 
-	crd := loadCRDFromFile(t, "crd-additional-columns.yaml")
-	resource := loadCRFromFile(t, "crd-resource.yaml")
+	crd := testutil.LoadUnstructuredFromFile(t, "crd-additional-columns.yaml")
+	resource := testutil.LoadUnstructuredFromFile(t, "crd-resource.yaml")
 
 	now := time.Now()
 	resource.SetCreationTimestamp(metav1.Time{Time: now})
@@ -78,11 +73,11 @@ func Test_CustomResourceListHandler_custom_columns(t *testing.T) {
 
 	list := testutil.ToUnstructuredList(t, resource)
 
-	got, err := CustomResourceListHandler(crd.Name, crd, list, tpo.link, false)
+	got, err := CustomResourceListHandler(crd, list, "v1", tpo.link)
 	require.NoError(t, err)
 
 	expected := component.NewTableWithRows(
-		"crontabs.stable.example.com", "We couldn't find any custom resources!",
+		"crontabs.stable.example.com/v1", "We couldn't find any custom resources!",
 		component.NewTableCols("Name", "Labels", "Spec", "Replicas", "Errors", "Resource Age", "Age"),
 		[]component.TableRow{
 			{
@@ -99,13 +94,17 @@ func Test_CustomResourceListHandler_custom_columns(t *testing.T) {
 	component.AssertEqual(t, expected, got)
 }
 
+func TestCustomResourceHandler(t *testing.T) {
+
+}
+
 func Test_printCustomResourceConfig(t *testing.T) {
 	cases := []struct {
 		name     string
 		crd      string
 		cr       string
 		expected component.Component
-		isErr    bool
+		wantErr  bool
 	}{
 		{
 			name: "with additional columns",
@@ -132,8 +131,8 @@ func Test_printCustomResourceConfig(t *testing.T) {
 
 	for _, tc := range cases {
 		t.Run(tc.name, func(t *testing.T) {
-			crd := loadCRDFromFile(t, tc.crd)
-			resource := loadCRFromFile(t, tc.cr)
+			crd := testutil.LoadUnstructuredFromFile(t, tc.crd)
+			resource := testutil.LoadUnstructuredFromFile(t, tc.cr)
 
 			now := time.Now()
 			resource.SetCreationTimestamp(metav1.Time{Time: now})
@@ -141,13 +140,8 @@ func Test_printCustomResourceConfig(t *testing.T) {
 			labels := map[string]string{"foo": "bar"}
 			resource.SetLabels(labels)
 
-			got, err := printCustomResourceConfig(resource, crd)
-			if tc.isErr {
-				require.Error(t, err)
-				return
-			}
-
-			require.NoError(t, err)
+			got, err := printCustomResourceConfig(crd, resource)
+			testutil.RequireErrorOrNot(t, tc.wantErr, err)
 
 			assert.Equal(t, tc.expected, got)
 		})
@@ -160,7 +154,7 @@ func Test_printCustomResourceStatus(t *testing.T) {
 		crd      string
 		cr       string
 		expected component.Component
-		isErr    bool
+		wantErr  bool
 	}{
 		{
 			name: "with additional columns",
@@ -183,8 +177,8 @@ func Test_printCustomResourceStatus(t *testing.T) {
 
 	for _, tc := range cases {
 		t.Run(tc.name, func(t *testing.T) {
-			crd := loadCRDFromFile(t, tc.crd)
-			resource := loadCRFromFile(t, tc.cr)
+			crd := testutil.LoadUnstructuredFromFile(t, tc.crd)
+			resource := testutil.LoadUnstructuredFromFile(t, tc.cr)
 
 			now := time.Now()
 			resource.SetCreationTimestamp(metav1.Time{Time: now})
@@ -192,13 +186,8 @@ func Test_printCustomResourceStatus(t *testing.T) {
 			labels := map[string]string{"foo": "bar"}
 			resource.SetLabels(labels)
 
-			got, err := printCustomResourceStatus(resource, crd)
-			if tc.isErr {
-				require.Error(t, err)
-				return
-			}
-
-			require.NoError(t, err)
+			got, err := printCustomResourceStatus(crd, resource)
+			testutil.RequireErrorOrNot(t, tc.wantErr, err)
 
 			assert.Equal(t, tc.expected, got)
 		})
@@ -211,7 +200,7 @@ func Test_printCustomColumn(t *testing.T) {
 		objectPath string
 		jsonPath   string
 		expected   string
-		isErr      bool
+		wantErr    bool
 	}{
 		{
 			name:       "simple",
@@ -229,7 +218,7 @@ func Test_printCustomColumn(t *testing.T) {
 			name:       "invalid json path",
 			objectPath: "certificate.yaml",
 			jsonPath:   ".status.conditions[?(@.type==\"Ready\"].status",
-			isErr:      true,
+			wantErr:    true,
 		},
 		{
 			name:       "execute error: not found",
@@ -241,45 +230,18 @@ func Test_printCustomColumn(t *testing.T) {
 
 	for _, tc := range cases {
 		t.Run(tc.name, func(t *testing.T) {
-			resource := loadCRFromFile(t, tc.objectPath)
+			resource := testutil.LoadUnstructuredFromFile(t, tc.objectPath)
 
-			def := apiextv1beta1.CustomResourceColumnDefinition{
+			def := octant.CustomResourceDefinitionPrinterColumn{
 				Name:     "name",
 				JSONPath: tc.jsonPath,
 			}
 
 			got, err := printCustomColumn(resource.Object, def)
-			if tc.isErr {
-				require.Error(t, err)
-				return
-			}
-
-			require.NoError(t, err)
+			testutil.RequireErrorOrNot(t, tc.wantErr, err)
 
 			assert.Equal(t, tc.expected, got)
 		})
 	}
 
 }
-
-func loadCRDFromFile(t *testing.T, filename string) *apiextv1beta1.CustomResourceDefinition {
-	crd := testutil.CreateCRD("crd")
-	testutil.LoadTypedObjectFromFile(t, filename, crd)
-
-	return crd
-}
-
-func loadCRFromFile(t *testing.T, filename string) *unstructured.Unstructured {
-	file, err := os.Open(filepath.Join("testdata", filename))
-	require.NoError(t, err)
-
-	decoder := yaml.NewYAMLOrJSONDecoder(file, 1024)
-	var m map[string]interface{}
-	require.NoError(t, decoder.Decode(&m))
-
-	resource := &unstructured.Unstructured{
-		Object: m,
-	}
-
-	return resource
-}
diff --git a/internal/printer/namespace_test.go b/internal/printer/namespace_test.go
index 467aaaa4..402aecb5 100644
--- a/internal/printer/namespace_test.go
+++ b/internal/printer/namespace_test.go
@@ -7,7 +7,6 @@ package printer
 
 import (
 	"context"
-	"fmt"
 	"testing"
 
 	"github.com/golang/mock/gomock"
@@ -194,7 +193,6 @@ func Test_createResourceLimitCPURow(t *testing.T) {
 		Min:  corev1.ResourceList{corev1.ResourceCPU: min},
 		Max:  corev1.ResourceList{corev1.ResourceCPU: max},
 	}
-	fmt.Printf("%t", cpu.Min.Cpu().IsZero())
 	_, _, created := createResourceLimitCPURow(cpu)
 	require.True(t, created)
 
diff --git a/internal/testutil/testutil.go b/internal/testutil/testutil.go
index c36eabb2..b5283a7e 100644
--- a/internal/testutil/testutil.go
+++ b/internal/testutil/testutil.go
@@ -7,6 +7,7 @@ package testutil
 
 import (
 	"io/ioutil"
+	"os"
 	"path/filepath"
 	"testing"
 	"time"
@@ -14,7 +15,9 @@ import (
 	"github.com/stretchr/testify/require"
 	"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/util/yaml"
 	"k8s.io/client-go/kubernetes/scheme"
 	"k8s.io/utils/pointer"
 )
@@ -26,7 +29,7 @@ func LoadObjectFromFile(t *testing.T, objectFile string) runtime.Object {
 	require.NoError(t, err)
 
 	object, _, err := scheme.Codecs.UniversalDeserializer().Decode(data, nil, nil)
-	require.NoError(t, err)
+	require.NoError(t, err, "unable to decode serialized data")
 
 	accessor := meta.NewAccessor()
 	namespace, err := accessor.Namespace(object)
@@ -38,6 +41,30 @@ func LoadObjectFromFile(t *testing.T, objectFile string) runtime.Object {
 	return object
 }
 
+// LoadUnstructuredFromFile loads an object from a file in the in `testdata` directory.
+// It will assign a `default` namespace if one is not set. This helper does not support
+// multiple objects in a YAML file.
+func LoadUnstructuredFromFile(t *testing.T, objectFile string) *unstructured.Unstructured {
+	f, err := os.Open(filepath.Join("testdata", objectFile))
+	require.NoError(t, err)
+	defer func() {
+		require.NoError(t, f.Close())
+	}()
+
+	d := yaml.NewYAMLOrJSONDecoder(f, 4096)
+
+	ext := runtime.RawExtension{}
+	require.NoError(t, d.Decode(&ext))
+
+	obj, _, err := unstructured.UnstructuredJSONScheme.Decode(ext.Raw, nil, nil)
+	require.NoError(t, err)
+
+	u, ok := obj.(*unstructured.Unstructured)
+	require.True(t, ok, "object is not an unstructured object")
+
+	return u
+}
+
 // LoadTypedObjectFromFile loads a file from the `testdata` directory. It will
 // assign it a `default` namespace if one is not set.
 func LoadTypedObjectFromFile(t *testing.T, objectFile string, into runtime.Object) {
@@ -83,3 +110,12 @@ func ToOwnerReferences(t *testing.T, object runtime.Object) []metav1.OwnerRefere
 func Time() time.Time {
 	return time.Unix(1547211430, 0)
 }
+
+// RequireErrorOrNot or not is a helper that requires an error or not.
+func RequireErrorOrNot(t *testing.T, wantErr bool, err error) {
+	if wantErr {
+		require.Error(t, err)
+		return
+	}
+	require.NoError(t, err)
+}