apiextensions-apiserver: add columns to CRD spec

This commit is contained in:
Dr. Stefan Schimanski 2018-03-09 18:47:53 +01:00
parent b9e46f5422
commit ecdc1638f6
12 changed files with 556 additions and 54 deletions

View File

@ -23,9 +23,12 @@ import (
"github.com/google/gofuzz" "github.com/google/gofuzz"
"k8s.io/apiextensions-apiserver/pkg/apis/apiextensions" "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
runtimeserializer "k8s.io/apimachinery/pkg/runtime/serializer" runtimeserializer "k8s.io/apimachinery/pkg/runtime/serializer"
) )
var swaggerMetadataDescriptions = metav1.ObjectMeta{}.SwaggerDoc()
// Funcs returns the fuzzer functions for the apiextensions apis. // Funcs returns the fuzzer functions for the apiextensions apis.
func Funcs(codecs runtimeserializer.CodecFactory) []interface{} { func Funcs(codecs runtimeserializer.CodecFactory) []interface{} {
return []interface{}{ return []interface{}{
@ -53,6 +56,11 @@ func Funcs(codecs runtimeserializer.CodecFactory) []interface{} {
} else if len(obj.Versions) != 0 { } else if len(obj.Versions) != 0 {
obj.Version = obj.Versions[0].Name obj.Version = obj.Versions[0].Name
} }
if len(obj.AdditionalPrinterColumns) == 0 {
obj.AdditionalPrinterColumns = []apiextensions.CustomResourceColumnDefinition{
{Name: "Age", Type: "date", Description: swaggerMetadataDescriptions["creationTimestamp"], JSONPath: ".metadata.creationTimestamp"},
}
}
}, },
func(obj *apiextensions.CustomResourceDefinition, c fuzz.Continue) { func(obj *apiextensions.CustomResourceDefinition, c fuzz.Continue) {
c.FuzzNoCustom(obj) c.FuzzNoCustom(obj)

View File

@ -49,6 +49,8 @@ type CustomResourceDefinitionSpec struct {
// major version, then minor version. An example sorted list of versions: // major version, then minor version. An example sorted list of versions:
// v10, v2, v1, v11beta2, v10beta3, v3beta1, v12alpha1, v11alpha2, foo1, foo10. // v10, v2, v1, v11beta2, v10beta3, v3beta1, v12alpha1, v11alpha2, foo1, foo10.
Versions []CustomResourceDefinitionVersion Versions []CustomResourceDefinitionVersion
// AdditionalPrinterColumns are additional columns shown e.g. in kubectl next to the name. Defaults to a created-at column.
AdditionalPrinterColumns []CustomResourceColumnDefinition
} }
type CustomResourceDefinitionVersion struct { type CustomResourceDefinitionVersion struct {
@ -61,6 +63,28 @@ type CustomResourceDefinitionVersion struct {
Storage bool Storage bool
} }
// CustomResourceColumnDefinition specifies a column for server side printing.
type CustomResourceColumnDefinition struct {
// name is a human readable name for the column.
Name string
// type is an OpenAPI type definition for this column.
// See https://github.com/OAI/OpenAPI-Specification/blob/master/versions/2.0.md#data-types for more.
Type string
// format is an optional OpenAPI type definition for this column. The 'name' format is applied
// to the primary identifier column to assist in clients identifying column is the resource name.
// See https://github.com/OAI/OpenAPI-Specification/blob/master/versions/2.0.md#data-types for more.
Format string
// description is a human readable description of this column.
Description string
// priority is an integer defining the relative importance of this column compared to others. Lower
// numbers are considered higher priority. Columns that may be omitted in limited space scenarios
// should be given a higher priority.
Priority int32
// JSONPath is a simple JSON path, i.e. without array notation.
JSONPath string
}
// CustomResourceDefinitionNames indicates the names to serve this CustomResourceDefinition // CustomResourceDefinitionNames indicates the names to serve this CustomResourceDefinition
type CustomResourceDefinitionNames struct { type CustomResourceDefinitionNames struct {
// Plural is the plural name of the resource to serve. It must match the name of the CustomResourceDefinition-registration // Plural is the plural name of the resource to serve. It must match the name of the CustomResourceDefinition-registration

View File

@ -19,9 +19,12 @@ package v1beta1
import ( import (
"strings" "strings"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/runtime"
) )
var swaggerMetadataDescriptions = metav1.ObjectMeta{}.SwaggerDoc()
func addDefaultingFuncs(scheme *runtime.Scheme) error { func addDefaultingFuncs(scheme *runtime.Scheme) error {
scheme.AddTypeDefaultingFunc(&CustomResourceDefinition{}, func(obj interface{}) { SetDefaults_CustomResourceDefinition(obj.(*CustomResourceDefinition)) }) scheme.AddTypeDefaultingFunc(&CustomResourceDefinition{}, func(obj interface{}) { SetDefaults_CustomResourceDefinition(obj.(*CustomResourceDefinition)) })
// TODO figure out why I can't seem to get my defaulter generated // TODO figure out why I can't seem to get my defaulter generated
@ -63,4 +66,9 @@ func SetDefaults_CustomResourceDefinitionSpec(obj *CustomResourceDefinitionSpec)
if len(obj.Version) == 0 && len(obj.Versions) != 0 { if len(obj.Version) == 0 && len(obj.Versions) != 0 {
obj.Version = obj.Versions[0].Name obj.Version = obj.Versions[0].Name
} }
if len(obj.AdditionalPrinterColumns) == 0 {
obj.AdditionalPrinterColumns = []CustomResourceColumnDefinition{
{Name: "Age", Type: "date", Description: swaggerMetadataDescriptions["creationTimestamp"], JSONPath: ".metadata.creationTimestamp"},
}
}
} }

View File

@ -52,6 +52,8 @@ type CustomResourceDefinitionSpec struct {
// major version, then minor version. An example sorted list of versions: // major version, then minor version. An example sorted list of versions:
// v10, v2, v1, v11beta2, v10beta3, v3beta1, v12alpha1, v11alpha2, foo1, foo10. // v10, v2, v1, v11beta2, v10beta3, v3beta1, v12alpha1, v11alpha2, foo1, foo10.
Versions []CustomResourceDefinitionVersion `json:"versions,omitempty" protobuf:"bytes,7,rep,name=versions"` Versions []CustomResourceDefinitionVersion `json:"versions,omitempty" protobuf:"bytes,7,rep,name=versions"`
// AdditionalPrinterColumns are additional columns shown e.g. in kubectl next to the name. Defaults to a created-at column.
AdditionalPrinterColumns []CustomResourceColumnDefinition `json:"additionalPrinterColumns,omitempty" protobuf:"bytes,8,rep,name=additionalPrinterColumns"`
} }
type CustomResourceDefinitionVersion struct { type CustomResourceDefinitionVersion struct {
@ -64,6 +66,28 @@ type CustomResourceDefinitionVersion struct {
Storage bool `json:"storage" protobuf:"varint,3,opt,name=storage"` Storage bool `json:"storage" protobuf:"varint,3,opt,name=storage"`
} }
// CustomResourceColumnDefinition specifies a column for server side printing.
type CustomResourceColumnDefinition struct {
// name is a human readable name for the column.
Name string `json:"name" protobuf:"bytes,1,opt,name=name"`
// type is an OpenAPI type definition for this column.
// See https://github.com/OAI/OpenAPI-Specification/blob/master/versions/2.0.md#data-types for more.
Type string `json:"type" protobuf:"bytes,2,opt,name=type"`
// format is an optional OpenAPI type definition for this column. The 'name' format is applied
// to the primary identifier column to assist in clients identifying column is the resource name.
// See https://github.com/OAI/OpenAPI-Specification/blob/master/versions/2.0.md#data-types for more.
Format string `json:"format,omitempty" protobuf:"bytes,3,opt,name=format"`
// description is a human readable description of this column.
Description string `json:"description,omitempty" protobuf:"bytes,4,opt,name=description"`
// priority is an integer defining the relative importance of this column compared to others. Lower
// numbers are considered higher priority. Columns that may be omitted in limited space scenarios
// should be given a higher priority.
Priority int32 `json:"priority,omitempty" protobuf:"bytes,5,opt,name=priority"`
// JSONPath is a simple JSON path, i.e. with array notation.
JSONPath string `json:"JSONPath" protobuf:"bytes,6,opt,name=JSONPath"`
}
// CustomResourceDefinitionNames indicates the names to serve this CustomResourceDefinition // CustomResourceDefinitionNames indicates the names to serve this CustomResourceDefinition
type CustomResourceDefinitionNames struct { type CustomResourceDefinitionNames struct {
// Plural is the plural name of the resource to serve. It must match the name of the CustomResourceDefinition-registration // Plural is the plural name of the resource to serve. It must match the name of the CustomResourceDefinition-registration

View File

@ -22,6 +22,7 @@ import (
"strings" "strings"
genericvalidation "k8s.io/apimachinery/pkg/api/validation" genericvalidation "k8s.io/apimachinery/pkg/api/validation"
"k8s.io/apimachinery/pkg/util/sets"
validationutil "k8s.io/apimachinery/pkg/util/validation" validationutil "k8s.io/apimachinery/pkg/util/validation"
"k8s.io/apimachinery/pkg/util/validation/field" "k8s.io/apimachinery/pkg/util/validation/field"
utilfeature "k8s.io/apiserver/pkg/util/feature" utilfeature "k8s.io/apiserver/pkg/util/feature"
@ -31,6 +32,11 @@ import (
apiextensionsfeatures "k8s.io/apiextensions-apiserver/pkg/features" apiextensionsfeatures "k8s.io/apiextensions-apiserver/pkg/features"
) )
var (
printerColumnDatatypes = sets.NewString("integer", "number", "string", "boolean", "date")
customResourceColumnDefinitionFormats = sets.NewString("int32", "int64", "float", "double", "byte", "date", "date-time", "password")
)
// ValidateCustomResourceDefinition statically validates // ValidateCustomResourceDefinition statically validates
func ValidateCustomResourceDefinition(obj *apiextensions.CustomResourceDefinition) field.ErrorList { func ValidateCustomResourceDefinition(obj *apiextensions.CustomResourceDefinition) field.ErrorList {
nameValidationFn := func(name string, prefix bool) []string { nameValidationFn := func(name string, prefix bool) []string {
@ -175,6 +181,12 @@ func ValidateCustomResourceDefinitionSpec(spec *apiextensions.CustomResourceDefi
allErrs = append(allErrs, field.Forbidden(fldPath.Child("subresources"), "disabled by feature-gate CustomResourceSubresources")) allErrs = append(allErrs, field.Forbidden(fldPath.Child("subresources"), "disabled by feature-gate CustomResourceSubresources"))
} }
for i := range spec.AdditionalPrinterColumns {
if errs := ValidateCustomResourceColumnDefinition(&spec.AdditionalPrinterColumns[i], fldPath.Child("columns").Index(i)); len(errs) > 0 {
allErrs = append(allErrs, errs...)
}
}
return allErrs return allErrs
} }
@ -238,6 +250,33 @@ func ValidateCustomResourceDefinitionNames(names *apiextensions.CustomResourceDe
return allErrs return allErrs
} }
// ValidateCustomResourceColumnDefinition statically validates a printer column.
func ValidateCustomResourceColumnDefinition(col *apiextensions.CustomResourceColumnDefinition, fldPath *field.Path) field.ErrorList {
allErrs := field.ErrorList{}
if len(col.Name) == 0 {
allErrs = append(allErrs, field.Required(fldPath.Child("header"), ""))
}
if len(col.Type) == 0 {
allErrs = append(allErrs, field.Required(fldPath.Child("type"), fmt.Sprintf("must be one of %s", strings.Join(printerColumnDatatypes.List(), ","))))
} else if !printerColumnDatatypes.Has(col.Type) {
allErrs = append(allErrs, field.Invalid(fldPath.Child("type"), col.Type, fmt.Sprintf("must be one of %s", strings.Join(printerColumnDatatypes.List(), ","))))
}
if len(col.Format) > 0 && !customResourceColumnDefinitionFormats.Has(col.Format) {
allErrs = append(allErrs, field.Invalid(fldPath.Child("format"), col.Format, fmt.Sprintf("must be one of %s", strings.Join(customResourceColumnDefinitionFormats.List(), ","))))
}
if len(col.JSONPath) == 0 {
allErrs = append(allErrs, field.Required(fldPath.Child("path"), ""))
} else if errs := validateSimpleJSONPath(col.JSONPath, fldPath.Child("path")); len(errs) > 0 {
allErrs = append(allErrs, errs...)
}
return allErrs
}
// specStandardValidator applies validations for different OpenAPI specification versions. // specStandardValidator applies validations for different OpenAPI specification versions.
type specStandardValidator interface { type specStandardValidator interface {
validate(spec *apiextensions.JSONSchemaProps, fldPath *field.Path) field.ErrorList validate(spec *apiextensions.JSONSchemaProps, fldPath *field.Path) field.ErrorList

View File

@ -370,6 +370,8 @@ func (r *crdHandler) GetCustomResourceListerCollectionDeleter(crd *apiextensions
return info.storages[info.storageVersion].CustomResource, nil return info.storages[info.storageVersion].CustomResource, nil
} }
var swaggerMetadataDescriptions = metav1.ObjectMeta{}.SwaggerDoc()
func (r *crdHandler) getOrCreateServingInfoFor(crd *apiextensions.CustomResourceDefinition) (*crdInfo, error) { func (r *crdHandler) getOrCreateServingInfoFor(crd *apiextensions.CustomResourceDefinition) (*crdInfo, error) {
storageMap := r.customStorage.Load().(crdStorageMap) storageMap := r.customStorage.Load().(crdStorageMap)
if ret, ok := storageMap[crd.UID]; ok { if ret, ok := storageMap[crd.UID]; ok {
@ -439,8 +441,7 @@ func (r *crdHandler) getOrCreateServingInfoFor(crd *apiextensions.CustomResource
scaleSpec = crd.Spec.Subresources.Scale scaleSpec = crd.Spec.Subresources.Scale
} }
// TODO: identify how to pass printer specification from the CRD table, err := tableconvertor.New(crd.Spec.AdditionalPrinterColumns)
table, err := tableconvertor.New(nil)
if err != nil { if err != nil {
glog.V(2).Infof("The CRD for %v has an invalid printer specification, falling back to default printing: %v", kind, err) glog.V(2).Infof("The CRD for %v has an invalid printer specification, falling back to default printing: %v", kind, err)
} }

View File

@ -21,12 +21,15 @@ import (
"reflect" "reflect"
"strings" "strings"
"testing" "testing"
"time"
autoscalingv1 "k8s.io/api/autoscaling/v1" autoscalingv1 "k8s.io/api/autoscaling/v1"
apiequality "k8s.io/apimachinery/pkg/api/equality" apiequality "k8s.io/apimachinery/pkg/api/equality"
"k8s.io/apimachinery/pkg/api/errors" "k8s.io/apimachinery/pkg/api/errors"
metainternal "k8s.io/apimachinery/pkg/apis/meta/internalversion"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
metav1beta1 "k8s.io/apimachinery/pkg/apis/meta/v1beta1"
"k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/runtime/schema" "k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/apimachinery/pkg/util/diff" "k8s.io/apimachinery/pkg/util/diff"
@ -72,8 +75,19 @@ func newStorage(t *testing.T) (customresource.CustomResourceStorage, *etcdtestin
status := &apiextensions.CustomResourceSubresourceStatus{} status := &apiextensions.CustomResourceSubresourceStatus{}
// TODO: identify how to pass printer specification from the CRD headers := []apiextensions.CustomResourceColumnDefinition{
table, _ := tableconvertor.New(nil) {Name: "Age", Type: "date", JSONPath: ".metadata.creationTimestamp"},
{Name: "Replicas", Type: "integer", JSONPath: ".spec.replicas"},
{Name: "Missing", Type: "string", JSONPath: ".spec.missing"},
{Name: "Invalid", Type: "integer", JSONPath: ".spec.string"},
{Name: "String", Type: "string", JSONPath: ".spec.string"},
{Name: "StringFloat64", Type: "string", JSONPath: ".spec.float64"},
{Name: "StringInt64", Type: "string", JSONPath: ".spec.replicas"},
{Name: "StringBool", Type: "string", JSONPath: ".spec.bool"},
{Name: "Float64", Type: "number", JSONPath: ".spec.float64"},
{Name: "Bool", Type: "boolean", JSONPath: ".spec.bool"},
}
table, _ := tableconvertor.New(headers)
storage := customresource.NewStorage( storage := customresource.NewStorage(
schema.GroupResource{Group: "mygroup.example.com", Resource: "noxus"}, schema.GroupResource{Group: "mygroup.example.com", Resource: "noxus"},
@ -114,9 +128,16 @@ func validNewCustomResource() *unstructured.Unstructured {
"metadata": map[string]interface{}{ "metadata": map[string]interface{}{
"namespace": "default", "namespace": "default",
"name": "foo", "name": "foo",
"creationTimestamp": time.Now().Add(-time.Hour*12 - 30*time.Minute).UTC().Format(time.RFC3339),
}, },
"spec": map[string]interface{}{ "spec": map[string]interface{}{
"replicas": int64(7), "replicas": int64(7),
"string": "string",
"float64": float64(3.1415926),
"bool": true,
"stringList": []interface{}{"foo", "bar"},
"mixedList": []interface{}{"foo", int64(42)},
"nonPrimitiveList": []interface{}{"foo", []interface{}{int64(1), int64(2)}},
}, },
}, },
} }
@ -225,6 +246,77 @@ func TestCategories(t *testing.T) {
} }
} }
func TestColumns(t *testing.T) {
storage, server := newStorage(t)
defer server.Terminate(t)
defer storage.CustomResource.Store.DestroyFunc()
ctx := genericapirequest.WithNamespace(genericapirequest.NewContext(), metav1.NamespaceDefault)
key := "/noxus/" + metav1.NamespaceDefault + "/foo"
validCustomResource := validNewCustomResource()
if err := storage.CustomResource.Storage.Create(ctx, key, validCustomResource, nil, 0); err != nil {
t.Fatalf("unexpected error: %v", err)
}
gottenList, err := storage.CustomResource.List(ctx, &metainternal.ListOptions{})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
tbl, err := storage.CustomResource.ConvertToTable(ctx, gottenList, &metav1beta1.TableOptions{})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
expectedColumns := []struct {
Name, Type string
}{
{"Name", "string"},
{"Age", "date"},
{"Replicas", "integer"},
{"Missing", "string"},
{"Invalid", "integer"},
{"String", "string"},
{"StringFloat64", "string"},
{"StringInt64", "string"},
{"StringBool", "string"},
{"Float64", "number"},
{"Bool", "boolean"},
}
if len(tbl.ColumnDefinitions) != len(expectedColumns) {
t.Fatalf("got %d columns, expected %d. Got: %+v", len(tbl.ColumnDefinitions), len(expectedColumns), tbl.ColumnDefinitions)
}
for i, d := range tbl.ColumnDefinitions {
if d.Name != expectedColumns[i].Name {
t.Errorf("got column %d name %q, expected %q", i, d.Name, expectedColumns[i].Name)
}
if d.Type != expectedColumns[i].Type {
t.Errorf("got column %d type %q, expected %q", i, d.Type, expectedColumns[i].Type)
}
}
expectedRows := [][]interface{}{
{
"foo",
"12h",
int64(7),
nil,
nil,
"string",
"3.1415926",
"7",
"true",
float64(3.1415926),
true,
},
}
for i, r := range tbl.Rows {
if !reflect.DeepEqual(r.Cells, expectedRows[i]) {
t.Errorf("got row %d with cells %#v, expected %#v", i, r.Cells, expectedRows[i])
}
}
}
func TestStatusUpdate(t *testing.T) { func TestStatusUpdate(t *testing.T) {
storage, server := newStorage(t) storage, server := newStorage(t)
defer server.Terminate(t) defer server.Terminate(t)

View File

@ -19,11 +19,11 @@ package tableconvertor
import ( import (
"bytes" "bytes"
"context" "context"
"encoding/json"
"fmt" "fmt"
"strings" "reflect"
"github.com/go-openapi/spec"
"k8s.io/apiextensions-apiserver/pkg/apis/apiextensions"
"k8s.io/apimachinery/pkg/api/meta" "k8s.io/apimachinery/pkg/api/meta"
metatable "k8s.io/apimachinery/pkg/api/meta/table" metatable "k8s.io/apimachinery/pkg/api/meta/table"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
@ -33,56 +33,46 @@ import (
"k8s.io/client-go/util/jsonpath" "k8s.io/client-go/util/jsonpath"
) )
const printColumnsKey = "x-kubernetes-print-columns"
var swaggerMetadataDescriptions = metav1.ObjectMeta{}.SwaggerDoc() var swaggerMetadataDescriptions = metav1.ObjectMeta{}.SwaggerDoc()
// New creates a new table convertor for the provided OpenAPI schema. If the printer definition cannot be parsed, // New creates a new table convertor for the provided CRD column definition. If the printer definition cannot be parsed,
// error will be returned along with a default table convertor. // error will be returned along with a default table convertor.
func New(extensions spec.Extensions) (rest.TableConvertor, error) { func New(crdColumns []apiextensions.CustomResourceColumnDefinition) (rest.TableConvertor, error) {
headers := []metav1beta1.TableColumnDefinition{ headers := []metav1beta1.TableColumnDefinition{
{Name: "Name", Type: "string", Format: "name", Description: swaggerMetadataDescriptions["name"]}, {Name: "Name", Type: "string", Format: "name", Description: swaggerMetadataDescriptions["name"]},
{Name: "Age", Type: "date", Description: swaggerMetadataDescriptions["creationTimestamp"]},
} }
c := &convertor{ c := &convertor{
headers: headers, headers: headers,
} }
format, ok := extensions.GetString(printColumnsKey)
if !ok { for _, col := range crdColumns {
return c, nil path := jsonpath.New(col.Name)
} if err := path.Parse(fmt.Sprintf("{%s}", col.JSONPath)); err != nil {
// "x-kubernetes-print-columns": "custom-columns=NAME:.metadata.name,RSRC:.metadata.resourceVersion" return c, fmt.Errorf("unrecognized column definition %q", col.JSONPath)
parts := strings.SplitN(format, "=", 2)
if len(parts) != 2 || parts[0] != "custom-columns" {
return c, fmt.Errorf("unrecognized column definition in 'x-kubernetes-print-columns', only support 'custom-columns=NAME=JSONPATH[,NAME=JSONPATH]'")
}
columnSpecs := strings.Split(parts[1], ",")
var columns []*jsonpath.JSONPath
for _, spec := range columnSpecs {
parts := strings.SplitN(spec, ":", 2)
if len(parts) != 2 || len(parts[0]) == 0 || len(parts[1]) == 0 {
return c, fmt.Errorf("unrecognized column definition in 'x-kubernetes-print-columns', must specify NAME=JSONPATH: %s", spec)
}
path := jsonpath.New(parts[0])
if err := path.Parse(parts[1]); err != nil {
return c, fmt.Errorf("unrecognized column definition in 'x-kubernetes-print-columns': %v", spec)
} }
path.AllowMissingKeys(true) path.AllowMissingKeys(true)
columns = append(columns, path)
headers = append(headers, metav1beta1.TableColumnDefinition{ desc := fmt.Sprintf("Custom resource definition column (in JSONPath format): %s", col.JSONPath)
Name: parts[0], if len(col.Description) > 0 {
Type: "string", desc = col.Description
Description: fmt.Sprintf("Custom resource definition column from OpenAPI (in JSONPath format): %s", parts[1]), }
c.additionalColumns = append(c.additionalColumns, path)
c.headers = append(c.headers, metav1beta1.TableColumnDefinition{
Name: col.Name,
Type: col.Type,
Format: col.Format,
Description: desc,
Priority: col.Priority,
}) })
} }
c.columns = columns
c.headers = headers
return c, nil return c, nil
} }
type convertor struct { type convertor struct {
headers []metav1beta1.TableColumnDefinition headers []metav1beta1.TableColumnDefinition
columns []*jsonpath.JSONPath additionalColumns []*jsonpath.JSONPath
} }
func (c *convertor) ConvertToTable(ctx context.Context, obj runtime.Object, tableOptions runtime.Object) (*metav1beta1.Table, error) { func (c *convertor) ConvertToTable(ctx context.Context, obj runtime.Object, tableOptions runtime.Object) (*metav1beta1.Table, error) {
@ -103,18 +93,80 @@ func (c *convertor) ConvertToTable(ctx context.Context, obj runtime.Object, tabl
var err error var err error
buf := &bytes.Buffer{} buf := &bytes.Buffer{}
table.Rows, err = metatable.MetaToTableRow(obj, func(obj runtime.Object, m metav1.Object, name, age string) ([]interface{}, error) { table.Rows, err = metatable.MetaToTableRow(obj, func(obj runtime.Object, m metav1.Object, name, age string) ([]interface{}, error) {
cells := make([]interface{}, 2, 2+len(c.columns)) cells := make([]interface{}, 1, 1+len(c.additionalColumns))
cells[0] = name cells[0] = name
cells[1] = age customHeaders := c.headers[1:]
for _, column := range c.columns { for i, column := range c.additionalColumns {
if err := column.Execute(buf, obj); err != nil { results, err := column.FindResults(obj.(runtime.Unstructured).UnstructuredContent())
if err != nil || len(results) == 0 || len(results[0]) == 0 {
cells = append(cells, nil) cells = append(cells, nil)
continue continue
} }
// as we only support simple JSON path, we can assume to have only one result (or none, filtered out above)
value := results[0][0].Interface()
if customHeaders[i].Type == "string" {
if err := column.PrintResults(buf, []reflect.Value{reflect.ValueOf(value)}); err == nil {
cells = append(cells, buf.String()) cells = append(cells, buf.String())
buf.Reset() buf.Reset()
} else {
cells = append(cells, nil)
}
} else {
cells = append(cells, cellForJSONValue(customHeaders[i].Type, value))
}
} }
return cells, nil return cells, nil
}) })
return table, err return table, err
} }
func cellForJSONValue(headerType string, value interface{}) interface{} {
if value == nil {
return nil
}
switch headerType {
case "integer":
switch typed := value.(type) {
case int64:
return typed
case float64:
return int64(typed)
case json.Number:
if i64, err := typed.Int64(); err == nil {
return i64
}
}
case "number":
switch typed := value.(type) {
case int64:
return float64(typed)
case float64:
return typed
case json.Number:
if f, err := typed.Float64(); err == nil {
return f
}
}
case "boolean":
if b, ok := value.(bool); ok {
return b
}
case "string":
if s, ok := value.(string); ok {
return s
}
case "date":
if typed, ok := value.(string); ok {
var timestamp metav1.Time
err := timestamp.UnmarshalQueryParameter(typed)
if err != nil {
return "<invalid>"
}
return metatable.ConvertToHumanReadableDateType(timestamp)
}
}
return nil
}

View File

@ -0,0 +1,68 @@
/*
Copyright 2018 The Kubernetes Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package tableconvertor
import (
"fmt"
"reflect"
"testing"
"time"
)
func Test_cellForJSONValue(t *testing.T) {
tests := []struct {
headerType string
value interface{}
want interface{}
}{
{"integer", int64(42), int64(42)},
{"integer", float64(3.14), int64(3)},
{"integer", true, nil},
{"integer", "foo", nil},
{"number", int64(42), float64(42)},
{"number", float64(3.14), float64(3.14)},
{"number", true, nil},
{"number", "foo", nil},
{"boolean", int64(42), nil},
{"boolean", float64(3.14), nil},
{"boolean", true, true},
{"boolean", "foo", nil},
{"string", int64(42), nil},
{"string", float64(3.14), nil},
{"string", true, nil},
{"string", "foo", "foo"},
{"date", int64(42), nil},
{"date", float64(3.14), nil},
{"date", true, nil},
{"date", time.Now().Add(-time.Hour*12 - 30*time.Minute).UTC().Format(time.RFC3339), "12h"},
{"date", time.Now().Add(+time.Hour*12 + 30*time.Minute).UTC().Format(time.RFC3339), "<invalid>"},
{"date", "", "<unknown>"},
{"unknown", "foo", nil},
}
for _, tt := range tests {
t.Run(fmt.Sprintf("%#v of type %s", tt.value, tt.headerType), func(t *testing.T) {
if got := cellForJSONValue(tt.headerType, tt.value); !reflect.DeepEqual(got, tt.want) {
t.Errorf("cellForJSONValue() = %#v, want %#v", got, tt.want)
}
})
}
}

View File

@ -0,0 +1,185 @@
/*
Copyright 2017 The Kubernetes Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package integration
import (
"fmt"
"testing"
"k8s.io/apiextensions-apiserver/pkg/client/clientset/clientset"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
metav1beta1 "k8s.io/apimachinery/pkg/apis/meta/v1beta1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/apimachinery/pkg/runtime/serializer"
"k8s.io/client-go/dynamic"
"k8s.io/client-go/rest"
apiextensionsv1beta1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1beta1"
"k8s.io/apiextensions-apiserver/test/integration/testserver"
)
func newTableCRD() *apiextensionsv1beta1.CustomResourceDefinition {
return &apiextensionsv1beta1.CustomResourceDefinition{
ObjectMeta: metav1.ObjectMeta{Name: "tables.mygroup.example.com"},
Spec: apiextensionsv1beta1.CustomResourceDefinitionSpec{
Group: "mygroup.example.com",
Version: "v1beta1",
Names: apiextensionsv1beta1.CustomResourceDefinitionNames{
Plural: "tables",
Singular: "table",
Kind: "Table",
ListKind: "TablemList",
},
Scope: apiextensionsv1beta1.ClusterScoped,
AdditionalPrinterColumns: []apiextensionsv1beta1.CustomResourceColumnDefinition{
{Name: "Age", Type: "date", JSONPath: ".metadata.creationTimestamp"},
{Name: "Alpha", Type: "string", JSONPath: ".spec.alpha"},
{Name: "Beta", Type: "integer", Description: "the beta field", Format: "int64", Priority: 42, JSONPath: ".spec.beta"},
{Name: "Gamma", Type: "integer", Description: "a column with wrongly typed values", JSONPath: ".spec.gamma"},
},
},
}
}
func newTableInstance(name string) *unstructured.Unstructured {
return &unstructured.Unstructured{
Object: map[string]interface{}{
"apiVersion": "mygroup.example.com/v1beta1",
"kind": "Table",
"metadata": map[string]interface{}{
"name": name,
},
"spec": map[string]interface{}{
"alpha": "foo_123",
"beta": 10,
"gamma": "bar",
"delta": "hello",
},
},
}
}
func TestTableGet(t *testing.T) {
stopCh, config, err := testserver.StartDefaultServer()
if err != nil {
t.Fatal(err)
}
defer close(stopCh)
apiExtensionClient, err := clientset.NewForConfig(config)
if err != nil {
t.Fatal(err)
}
dynamicClient, err := dynamic.NewForConfig(config)
if err != nil {
t.Fatal(err)
}
crd := newTableCRD()
crd, err = testserver.CreateNewCustomResourceDefinition(crd, apiExtensionClient, dynamicClient)
if err != nil {
t.Fatal(err)
}
crd, err = apiExtensionClient.ApiextensionsV1beta1().CustomResourceDefinitions().Get(crd.Name, metav1.GetOptions{})
if err != nil {
t.Fatal(err)
}
t.Logf("table crd created: %#v", crd)
crClient := newNamespacedCustomResourceClient("", dynamicClient, crd)
foo, err := crClient.Create(newTableInstance("foo"))
if err != nil {
t.Fatalf("unable to create noxu instance: %v", err)
}
t.Logf("foo created: %#v", foo.UnstructuredContent())
gv := schema.GroupVersion{Group: crd.Spec.Group, Version: crd.Spec.Version}
gvk := gv.WithKind(crd.Spec.Names.Kind)
scheme := runtime.NewScheme()
codecs := serializer.NewCodecFactory(scheme)
parameterCodec := runtime.NewParameterCodec(scheme)
metav1.AddToGroupVersion(scheme, gv)
scheme.AddKnownTypes(gv, &metav1beta1.Table{}, &metav1beta1.TableOptions{})
scheme.AddKnownTypes(metav1beta1.SchemeGroupVersion, &metav1beta1.Table{}, &metav1beta1.TableOptions{})
crConfig := *config
crConfig.GroupVersion = &gv
crConfig.APIPath = "/apis"
crConfig.NegotiatedSerializer = serializer.DirectCodecFactory{CodecFactory: codecs}
crRestClient, err := rest.RESTClientFor(&crConfig)
if err != nil {
t.Fatal(err)
}
ret, err := crRestClient.Get().
Resource(crd.Spec.Names.Plural).
SetHeader("Accept", fmt.Sprintf("application/json;as=Table;v=%s;g=%s, application/json", metav1beta1.SchemeGroupVersion.Version, metav1beta1.GroupName)).
VersionedParams(&metav1beta1.TableOptions{}, parameterCodec).
Do().
Get()
if err != nil {
t.Fatalf("failed to list %v resources: %v", gvk, err)
}
tbl, ok := ret.(*metav1beta1.Table)
if !ok {
t.Fatalf("expected metav1beta1.Table, got %T", ret)
}
t.Logf("%v table list: %#v", gvk, tbl)
if got, expected := len(tbl.ColumnDefinitions), 5; got != expected {
t.Errorf("expected %d headers, got %d", expected, got)
} else {
alpha := metav1beta1.TableColumnDefinition{Name: "Alpha", Type: "string", Format: "", Description: "Custom resource definition column (in JSONPath format): .spec.alpha", Priority: 0}
if got, expected := tbl.ColumnDefinitions[2], alpha; got != expected {
t.Errorf("expected column definition %#v, got %#v", expected, got)
}
beta := metav1beta1.TableColumnDefinition{Name: "Beta", Type: "integer", Format: "int64", Description: "the beta field", Priority: 42}
if got, expected := tbl.ColumnDefinitions[3], beta; got != expected {
t.Errorf("expected column definition %#v, got %#v", expected, got)
}
gamma := metav1beta1.TableColumnDefinition{Name: "Gamma", Type: "integer", Description: "a column with wrongly typed values"}
if got, expected := tbl.ColumnDefinitions[4], gamma; got != expected {
t.Errorf("expected column definition %#v, got %#v", expected, got)
}
}
if got, expected := len(tbl.Rows), 1; got != expected {
t.Errorf("expected %d rows, got %d", expected, got)
} else if got, expected := len(tbl.Rows[0].Cells), 5; got != expected {
t.Errorf("expected %d cells, got %d", expected, got)
} else {
if got, expected := tbl.Rows[0].Cells[0], "foo"; got != expected {
t.Errorf("expected cell[0] to equal %q, got %q", expected, got)
}
if got, expected := tbl.Rows[0].Cells[2], "foo_123"; got != expected {
t.Errorf("expected cell[2] to equal %q, got %q", expected, got)
}
if got, expected := tbl.Rows[0].Cells[3], int64(10); got != expected {
t.Errorf("expected cell[3] to equal %#v, got %#v", expected, got)
}
if got, expected := tbl.Rows[0].Cells[4], interface{}(nil); got != expected {
t.Errorf("expected cell[3] to equal %#v although the type does not match the column, got %#v", expected, got)
}
}
}

View File

@ -53,7 +53,7 @@ func MetaToTableRow(obj runtime.Object, rowFn func(obj runtime.Object, m metav1.
row := metav1beta1.TableRow{ row := metav1beta1.TableRow{
Object: runtime.RawExtension{Object: obj}, Object: runtime.RawExtension{Object: obj},
} }
row.Cells, err = rowFn(obj, m, m.GetName(), translateTimestamp(m.GetCreationTimestamp())) row.Cells, err = rowFn(obj, m, m.GetName(), ConvertToHumanReadableDateType(m.GetCreationTimestamp()))
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -61,9 +61,9 @@ func MetaToTableRow(obj runtime.Object, rowFn func(obj runtime.Object, m metav1.
return rows, nil return rows, nil
} }
// translateTimestamp returns the elapsed time since timestamp in // ConvertToHumanReadableDateType returns the elapsed time since timestamp in
// human-readable approximation. // human-readable approximation.
func translateTimestamp(timestamp metav1.Time) string { func ConvertToHumanReadableDateType(timestamp metav1.Time) string {
if timestamp.IsZero() { if timestamp.IsZero() {
return "<unknown>" return "<unknown>"
} }

View File

@ -1320,7 +1320,7 @@ func (t *Tester) testListTableConversion(obj runtime.Object, assignFn AssignFunc
t.Errorf("column %d has no name", j) t.Errorf("column %d has no name", j)
} }
switch column.Type { switch column.Type {
case "string", "date", "integer": case "string", "date", "integer", "number", "boolean":
default: default:
t.Errorf("column %d has unexpected type: %q", j, column.Type) t.Errorf("column %d has unexpected type: %q", j, column.Type)
} }
@ -1342,13 +1342,14 @@ func (t *Tester) testListTableConversion(obj runtime.Object, assignFn AssignFunc
} }
for i, row := range table.Rows { for i, row := range table.Rows {
if len(row.Cells) != len(table.ColumnDefinitions) { if len(row.Cells) != len(table.ColumnDefinitions) {
t.Errorf("row %d did not have the correct number of cells: %d in %v", i, len(table.ColumnDefinitions), row.Cells) t.Errorf("row %d did not have the correct number of cells: %d in %v, expected %d", i, len(row.Cells), row.Cells, len(table.ColumnDefinitions))
} }
for j, cell := range row.Cells { for j, cell := range row.Cells {
// do not add to this test without discussion - may break clients // do not add to this test without discussion - may break clients
switch cell.(type) { switch cell.(type) {
case float64, int64, int32, int, string, bool: case float64, int64, int32, int, string, bool:
case []interface{}: case []interface{}:
case nil:
default: default:
t.Errorf("row %d, cell %d has an unrecognized type, only JSON serialization safe types are allowed: %T ", i, j, cell) t.Errorf("row %d, cell %d has an unrecognized type, only JSON serialization safe types are allowed: %T ", i, j, cell)
} }