From b485821bb146a91371ba6a705a3001bf4bfa0ca6 Mon Sep 17 00:00:00 2001 From: Brendan Burns Date: Wed, 2 Sep 2015 21:06:48 -0700 Subject: [PATCH 1/3] Enable custom columns printing in kubectl --- pkg/kubectl/custom_column_printer.go | 95 +++++++++++++ pkg/kubectl/custom_column_printer_test.go | 165 ++++++++++++++++++++++ pkg/kubectl/resource_printer.go | 14 ++ 3 files changed, 274 insertions(+) diff --git a/pkg/kubectl/custom_column_printer.go b/pkg/kubectl/custom_column_printer.go index bbbb16e1444..4d107867713 100644 --- a/pkg/kubectl/custom_column_printer.go +++ b/pkg/kubectl/custom_column_printer.go @@ -17,6 +17,8 @@ limitations under the License. package kubectl import ( + "bufio" + "bytes" "fmt" "io" "reflect" @@ -35,6 +37,95 @@ const ( flags = 0 ) +// MassageJSONPath attempts to be flexible with JSONPath expressions, it accepts: +// * metadata.name (no leading '.' or curly brances '{...}' +// * {metadata.name} (no leading '.') +// * .metadata.name (no curly braces '{...}') +// * {.metadata.name} (complete expression) +// And transforms them all into a valid jsonpat expression: +// {.metadata.name} +func massageJSONPath(pathExpression string) string { + if len(pathExpression) == 0 { + return pathExpression + } + if pathExpression[0] != '{' { + if pathExpression[0] == '.' { + return fmt.Sprintf("{%s}", pathExpression) + } else { + return fmt.Sprintf("{.%s}", pathExpression) + } + } else { + if pathExpression[1] == '.' { + return pathExpression + } else { + return fmt.Sprintf("{.%s}", pathExpression[1:len(pathExpression)-1]) + } + } +} + +// NewCustomColumnsPrinterFromSpec creates a custom columns printer from a comma separated list of
: pairs. +// e.g. NAME:metadata.name,API_VERSION:apiVersion creates a printer that prints: +// +// NAME API_VERSION +// foo bar +func NewCustomColumnsPrinterFromSpec(spec string) (*CustomColumnsPrinter, error) { + parts := strings.Split(spec, ",") + if len(parts) == 0 { + return nil, fmt.Errorf("custom-columns format specified but no custom columns given") + } + columns := make([]Column, len(parts)) + for ix := range parts { + colSpec := strings.Split(parts[ix], ":") + if len(colSpec) != 2 { + return nil, fmt.Errorf("unexpected custom-columns spec: %s, expected
:", parts[ix]) + } + columns[ix] = Column{Header: colSpec[0], FieldSpec: massageJSONPath(colSpec[1])} + } + return &CustomColumnsPrinter{Columns: columns}, nil +} + +func splitOnWhitespace(line string) []string { + lineScanner := bufio.NewScanner(bytes.NewBufferString(line)) + lineScanner.Split(bufio.ScanWords) + result := []string{} + for lineScanner.Scan() { + result = append(result, lineScanner.Text()) + } + return result +} + +// NewCustomColumnsPrinterFromTemplate creates a custom columns printer from a template stream. The template is expected +// to consist of two lines, whitespace separated. The first line is the header line, the second line is the jsonpath field spec +// For example the template below: +// NAME API_VERSION +// {metadata.name} {apiVersion} +func NewCustomColumnsPrinterFromTemplate(templateReader io.Reader) (*CustomColumnsPrinter, error) { + scanner := bufio.NewScanner(templateReader) + if !scanner.Scan() { + return nil, fmt.Errorf("invalid template, missing header line") + } + headers := splitOnWhitespace(scanner.Text()) + + if !scanner.Scan() { + return nil, fmt.Errorf("invalid template, missing spec line") + } + specs := splitOnWhitespace(scanner.Text()) + fmt.Printf("SPECS: %v", specs) + + if len(headers) != len(specs) { + return nil, fmt.Errorf("number of headers (%d) and field specifications (%d) don't match", len(headers), len(specs)) + } + + columns := make([]Column, len(headers)) + for ix := range headers { + columns[ix] = Column{ + Header: headers[ix], + FieldSpec: massageJSONPath(specs[ix]), + } + } + return &CustomColumnsPrinter{Columns: columns}, nil +} + // Column represents a user specified column type Column struct { // The header to print above the column, general style is ALL_CAPS @@ -105,3 +196,7 @@ func (s *CustomColumnsPrinter) printOneObject(obj runtime.Object, parsers []*jso fmt.Fprintln(out, strings.Join(columns, "\t")) return nil } + +func (s *CustomColumnsPrinter) HandledResources() []string { + return []string{} +} diff --git a/pkg/kubectl/custom_column_printer_test.go b/pkg/kubectl/custom_column_printer_test.go index 62c3aea9dc6..245e1111ee8 100644 --- a/pkg/kubectl/custom_column_printer_test.go +++ b/pkg/kubectl/custom_column_printer_test.go @@ -18,12 +18,177 @@ package kubectl import ( "bytes" + "reflect" "testing" "k8s.io/kubernetes/pkg/api/v1" "k8s.io/kubernetes/pkg/runtime" ) +func TestMassageJSONPath(t *testing.T) { + tests := []struct { + input string + expectedOutput string + }{ + {input: "foo.bar", expectedOutput: "{.foo.bar}"}, + {input: "{foo.bar}", expectedOutput: "{.foo.bar}"}, + {input: ".foo.bar", expectedOutput: "{.foo.bar}"}, + {input: "{.foo.bar}", expectedOutput: "{.foo.bar}"}, + {input: "", expectedOutput: ""}, + } + for _, test := range tests { + output := massageJSONPath(test.input) + if output != test.expectedOutput { + t.Errorf("expected: %s, saw: %s", test.expectedOutput, output) + } + } +} + +func TestNewColumnPrinterFromSpec(t *testing.T) { + tests := []struct { + spec string + expectedColumns []Column + expectErr bool + name string + }{ + { + spec: "", + expectErr: true, + name: "empty", + }, + { + spec: "invalid", + expectErr: true, + name: "invalid1", + }, + { + spec: "invalid=foobar", + expectErr: true, + name: "invalid2", + }, + { + spec: "invalid,foobar:blah", + expectErr: true, + name: "invalid3", + }, + { + spec: "NAME:metadata.name,API_VERSION:apiVersion", + name: "ok", + expectedColumns: []Column{ + { + Header: "NAME", + FieldSpec: "{.metadata.name}", + }, + { + Header: "API_VERSION", + FieldSpec: "{.apiVersion}", + }, + }, + }, + } + for _, test := range tests { + printer, err := NewCustomColumnsPrinterFromSpec(test.spec) + if test.expectErr { + if err == nil { + t.Errorf("[%s] unexpected non-error", test.name) + } + continue + } + if !test.expectErr && err != nil { + t.Errorf("[%s] unexpected error: %v", test.name, err) + continue + } + + if !reflect.DeepEqual(test.expectedColumns, printer.Columns) { + t.Errorf("[%s]\nexpected:\n%v\nsaw:\n%v\n", test.name, test.expectedColumns, printer.Columns) + } + + } +} + +const exampleTemplateOne = `NAME API_VERSION +{metadata.name} {apiVersion}` + +const exampleTemplateTwo = `NAME API_VERSION + {metadata.name} {apiVersion}` + +func TestNewColumnPrinterFromTemplate(t *testing.T) { + tests := []struct { + spec string + expectedColumns []Column + expectErr bool + name string + }{ + { + spec: "", + expectErr: true, + name: "empty", + }, + { + spec: "invalid", + expectErr: true, + name: "invalid1", + }, + { + spec: "invalid=foobar", + expectErr: true, + name: "invalid2", + }, + { + spec: "invalid,foobar:blah", + expectErr: true, + name: "invalid3", + }, + { + spec: exampleTemplateOne, + name: "ok", + expectedColumns: []Column{ + { + Header: "NAME", + FieldSpec: "{.metadata.name}", + }, + { + Header: "API_VERSION", + FieldSpec: "{.apiVersion}", + }, + }, + }, + { + spec: exampleTemplateTwo, + name: "ok-2", + expectedColumns: []Column{ + { + Header: "NAME", + FieldSpec: "{.metadata.name}", + }, + { + Header: "API_VERSION", + FieldSpec: "{.apiVersion}", + }, + }, + }, + } + for _, test := range tests { + reader := bytes.NewBufferString(test.spec) + printer, err := NewCustomColumnsPrinterFromTemplate(reader) + if test.expectErr { + if err == nil { + t.Errorf("[%s] unexpected non-error", test.name) + } + continue + } + if !test.expectErr && err != nil { + t.Errorf("[%s] unexpected error: %v", test.name, err) + continue + } + + if !reflect.DeepEqual(test.expectedColumns, printer.Columns) { + t.Errorf("[%s]\nexpected:\n%v\nsaw:\n%v\n", test.name, test.expectedColumns, printer.Columns) + } + + } +} + func TestColumnPrint(t *testing.T) { tests := []struct { columns []Column diff --git a/pkg/kubectl/resource_printer.go b/pkg/kubectl/resource_printer.go index fc9ea6b58dd..e7b044348c6 100644 --- a/pkg/kubectl/resource_printer.go +++ b/pkg/kubectl/resource_printer.go @@ -23,6 +23,7 @@ import ( "fmt" "io" "io/ioutil" + "os" "reflect" "sort" "strings" @@ -98,6 +99,19 @@ func GetPrinter(format, formatArgument string) (ResourcePrinter, bool, error) { printer, err = NewJSONPathPrinter(string(data)) if err != nil { return nil, false, fmt.Errorf("error parsing template %s, %v\n", string(data), err) + } + case "custom-columns": + var err error + if printer, err = NewCustomColumnsPrinterFromSpec(formatArgument); err != nil { + return nil, false, err + } + case "custom-columns-file": + file, err := os.Open(formatArgument) + if err != nil { + return nil, false, fmt.Errorf("error reading template %s, %v\n", formatArgument, err) + } + if printer, err = NewCustomColumnsPrinterFromTemplate(file); err != nil { + return nil, false, err } case "wide": fallthrough From 56a1cd76cbac4b0578f5238fc56f6e6c6eb4f3d9 Mon Sep 17 00:00:00 2001 From: Brendan Burns Date: Tue, 8 Sep 2015 18:28:58 -0700 Subject: [PATCH 2/3] Address changes. --- pkg/kubectl/custom_column_printer.go | 47 ++++++++++++++--------- pkg/kubectl/custom_column_printer_test.go | 19 ++++++++- pkg/kubectl/resource_printer.go | 2 +- 3 files changed, 47 insertions(+), 21 deletions(-) diff --git a/pkg/kubectl/custom_column_printer.go b/pkg/kubectl/custom_column_printer.go index 4d107867713..cc0dd5cd13e 100644 --- a/pkg/kubectl/custom_column_printer.go +++ b/pkg/kubectl/custom_column_printer.go @@ -22,6 +22,7 @@ import ( "fmt" "io" "reflect" + "regexp" "strings" "text/tabwriter" @@ -37,6 +38,8 @@ const ( flags = 0 ) +var jsonRegexp = regexp.MustCompile("^\\{\\.?([^{}]+)\\}$|^\\.?([^{}]+)$") + // MassageJSONPath attempts to be flexible with JSONPath expressions, it accepts: // * metadata.name (no leading '.' or curly brances '{...}' // * {metadata.name} (no leading '.') @@ -44,23 +47,24 @@ const ( // * {.metadata.name} (complete expression) // And transforms them all into a valid jsonpat expression: // {.metadata.name} -func massageJSONPath(pathExpression string) string { +func massageJSONPath(pathExpression string) (string, error) { if len(pathExpression) == 0 { - return pathExpression + return pathExpression, nil } - if pathExpression[0] != '{' { - if pathExpression[0] == '.' { - return fmt.Sprintf("{%s}", pathExpression) - } else { - return fmt.Sprintf("{.%s}", pathExpression) - } + submatches := jsonRegexp.FindStringSubmatch(pathExpression) + if submatches == nil { + return "", fmt.Errorf("unexpected path string, expected a 'name1.name2' or '.name1.name2' or '{name1.name2}' or '{.name1.name2}'") + } + if len(submatches) != 3 { + return "", fmt.Errorf("unexpected submatch list: %v", submatches) + } + var fieldSpec string + if len(submatches[1]) != 0 { + fieldSpec = submatches[1] } else { - if pathExpression[1] == '.' { - return pathExpression - } else { - return fmt.Sprintf("{.%s}", pathExpression[1:len(pathExpression)-1]) - } + fieldSpec = submatches[2] } + return fmt.Sprintf("{.%s}", fieldSpec), nil } // NewCustomColumnsPrinterFromSpec creates a custom columns printer from a comma separated list of
: pairs. @@ -79,7 +83,11 @@ func NewCustomColumnsPrinterFromSpec(spec string) (*CustomColumnsPrinter, error) if len(colSpec) != 2 { return nil, fmt.Errorf("unexpected custom-columns spec: %s, expected
:", parts[ix]) } - columns[ix] = Column{Header: colSpec[0], FieldSpec: massageJSONPath(colSpec[1])} + spec, err := massageJSONPath(colSpec[1]) + if err != nil { + return nil, err + } + columns[ix] = Column{Header: colSpec[0], FieldSpec: spec} } return &CustomColumnsPrinter{Columns: columns}, nil } @@ -102,15 +110,14 @@ func splitOnWhitespace(line string) []string { func NewCustomColumnsPrinterFromTemplate(templateReader io.Reader) (*CustomColumnsPrinter, error) { scanner := bufio.NewScanner(templateReader) if !scanner.Scan() { - return nil, fmt.Errorf("invalid template, missing header line") + return nil, fmt.Errorf("invalid template, missing header line. Expected format is one line of space separated headers, one line of space separated column specs.") } headers := splitOnWhitespace(scanner.Text()) if !scanner.Scan() { - return nil, fmt.Errorf("invalid template, missing spec line") + return nil, fmt.Errorf("invalid template, missing spec line. Expected format is one line of space separated headers, one line of space separated column specs.") } specs := splitOnWhitespace(scanner.Text()) - fmt.Printf("SPECS: %v", specs) if len(headers) != len(specs) { return nil, fmt.Errorf("number of headers (%d) and field specifications (%d) don't match", len(headers), len(specs)) @@ -118,9 +125,13 @@ func NewCustomColumnsPrinterFromTemplate(templateReader io.Reader) (*CustomColum columns := make([]Column, len(headers)) for ix := range headers { + spec, err := massageJSONPath(specs[ix]) + if err != nil { + return nil, err + } columns[ix] = Column{ Header: headers[ix], - FieldSpec: massageJSONPath(specs[ix]), + FieldSpec: spec, } } return &CustomColumnsPrinter{Columns: columns}, nil diff --git a/pkg/kubectl/custom_column_printer_test.go b/pkg/kubectl/custom_column_printer_test.go index 245e1111ee8..ac9fc9699ea 100644 --- a/pkg/kubectl/custom_column_printer_test.go +++ b/pkg/kubectl/custom_column_printer_test.go @@ -29,17 +29,32 @@ func TestMassageJSONPath(t *testing.T) { tests := []struct { input string expectedOutput string + expectErr bool }{ {input: "foo.bar", expectedOutput: "{.foo.bar}"}, {input: "{foo.bar}", expectedOutput: "{.foo.bar}"}, {input: ".foo.bar", expectedOutput: "{.foo.bar}"}, {input: "{.foo.bar}", expectedOutput: "{.foo.bar}"}, {input: "", expectedOutput: ""}, + {input: "{foo.bar", expectErr: true}, + {input: "foo.bar}", expectErr: true}, + {input: "{foo.bar}}", expectErr: true}, + {input: "{{foo.bar}", expectErr: true}, } for _, test := range tests { - output := massageJSONPath(test.input) + output, err := massageJSONPath(test.input) + if err != nil && !test.expectErr { + t.Errorf("unexpected error: %v", err) + continue + } + if test.expectErr { + if err == nil { + t.Error("unexpected non-error") + } + continue + } if output != test.expectedOutput { - t.Errorf("expected: %s, saw: %s", test.expectedOutput, output) + t.Errorf("input: %s, expected: %s, saw: %s", test.input, test.expectedOutput, output) } } } diff --git a/pkg/kubectl/resource_printer.go b/pkg/kubectl/resource_printer.go index e7b044348c6..41836b51f92 100644 --- a/pkg/kubectl/resource_printer.go +++ b/pkg/kubectl/resource_printer.go @@ -99,7 +99,7 @@ func GetPrinter(format, formatArgument string) (ResourcePrinter, bool, error) { printer, err = NewJSONPathPrinter(string(data)) if err != nil { return nil, false, fmt.Errorf("error parsing template %s, %v\n", string(data), err) - } + } case "custom-columns": var err error if printer, err = NewCustomColumnsPrinterFromSpec(formatArgument); err != nil { From 2b3bb335690d77214e2c82dd64cd45e946f0112b Mon Sep 17 00:00:00 2001 From: Brendan Burns Date: Thu, 10 Sep 2015 13:07:13 -0700 Subject: [PATCH 3/3] Address comments. --- pkg/kubectl/custom_column_printer.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pkg/kubectl/custom_column_printer.go b/pkg/kubectl/custom_column_printer.go index cc0dd5cd13e..e48003fac60 100644 --- a/pkg/kubectl/custom_column_printer.go +++ b/pkg/kubectl/custom_column_printer.go @@ -73,10 +73,10 @@ func massageJSONPath(pathExpression string) (string, error) { // NAME API_VERSION // foo bar func NewCustomColumnsPrinterFromSpec(spec string) (*CustomColumnsPrinter, error) { - parts := strings.Split(spec, ",") - if len(parts) == 0 { + if len(spec) == 0 { return nil, fmt.Errorf("custom-columns format specified but no custom columns given") } + parts := strings.Split(spec, ",") columns := make([]Column, len(parts)) for ix := range parts { colSpec := strings.Split(parts[ix], ":")