diff --git a/pkg/kubectl/custom_column_printer.go b/pkg/kubectl/custom_column_printer.go index bbbb16e1444..e48003fac60 100644 --- a/pkg/kubectl/custom_column_printer.go +++ b/pkg/kubectl/custom_column_printer.go @@ -17,9 +17,12 @@ limitations under the License. package kubectl import ( + "bufio" + "bytes" "fmt" "io" "reflect" + "regexp" "strings" "text/tabwriter" @@ -35,6 +38,105 @@ 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 '.') +// * .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, error) { + if len(pathExpression) == 0 { + return pathExpression, nil + } + 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 { + fieldSpec = submatches[2] + } + return fmt.Sprintf("{.%s}", fieldSpec), nil +} + +// 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) { + 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], ":") + if len(colSpec) != 2 { + return nil, fmt.Errorf("unexpected custom-columns spec: %s, expected
:", parts[ix]) + } + spec, err := massageJSONPath(colSpec[1]) + if err != nil { + return nil, err + } + columns[ix] = Column{Header: colSpec[0], FieldSpec: spec} + } + 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. 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. Expected format is one line of space separated headers, one line of space separated column specs.") + } + specs := splitOnWhitespace(scanner.Text()) + + 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 { + spec, err := massageJSONPath(specs[ix]) + if err != nil { + return nil, err + } + columns[ix] = Column{ + Header: headers[ix], + FieldSpec: spec, + } + } + 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 +207,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..ac9fc9699ea 100644 --- a/pkg/kubectl/custom_column_printer_test.go +++ b/pkg/kubectl/custom_column_printer_test.go @@ -18,12 +18,192 @@ 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 + 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, 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("input: %s, expected: %s, saw: %s", test.input, 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 c847d7e4ed8..a32e4519835 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" @@ -100,6 +101,19 @@ func GetPrinter(format, formatArgument string) (ResourcePrinter, bool, error) { 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 case "":