mirror of https://github.com/fluxcd/cli-utils.git
Merge pull request #122 from mortent/TablePrinterLib
Break out functionality for table printing into library
This commit is contained in:
commit
c813e58c26
|
|
@ -0,0 +1,11 @@
|
||||||
|
// Copyright 2020 The Kubernetes Authors.
|
||||||
|
// SPDX-License-Identifier: Apache-2.0
|
||||||
|
|
||||||
|
package common
|
||||||
|
|
||||||
|
const (
|
||||||
|
// RESET is the escape sequence for unsetting any previous commands.
|
||||||
|
RESET = 0
|
||||||
|
// ESC is the escape sequence used to send ANSI commands in the terminal.
|
||||||
|
ESC = 27
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,46 @@
|
||||||
|
// Copyright 2020 The Kubernetes Authors.
|
||||||
|
// SPDX-License-Identifier: Apache-2.0
|
||||||
|
|
||||||
|
package common
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"sigs.k8s.io/cli-utils/pkg/kstatus/status"
|
||||||
|
)
|
||||||
|
|
||||||
|
// color is a type that captures the ANSI code for colors on the
|
||||||
|
// terminal.
|
||||||
|
type Color int
|
||||||
|
|
||||||
|
var (
|
||||||
|
RED Color = 31
|
||||||
|
GREEN Color = 32
|
||||||
|
YELLOW Color = 33
|
||||||
|
)
|
||||||
|
|
||||||
|
// SprintfWithColor formats according to the provided pattern and returns
|
||||||
|
// the result as a string with the necessary ansii escape codes for
|
||||||
|
// color
|
||||||
|
func SprintfWithColor(color Color, format string, a ...interface{}) string {
|
||||||
|
return fmt.Sprintf("%c[%dm", ESC, color) +
|
||||||
|
fmt.Sprintf(format, a...) +
|
||||||
|
fmt.Sprintf("%c[%dm", ESC, RESET)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ColorForStatus returns the appropriate Color, which represents
|
||||||
|
// the ansii escape code, for different status values.
|
||||||
|
func ColorForStatus(s status.Status) (color Color, setColor bool) {
|
||||||
|
switch s {
|
||||||
|
case status.CurrentStatus:
|
||||||
|
color = GREEN
|
||||||
|
setColor = true
|
||||||
|
case status.InProgressStatus:
|
||||||
|
color = YELLOW
|
||||||
|
setColor = true
|
||||||
|
case status.FailedStatus:
|
||||||
|
color = RED
|
||||||
|
setColor = true
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,70 @@
|
||||||
|
// Copyright 2020 The Kubernetes Authors.
|
||||||
|
// SPDX-License-Identifier: Apache-2.0
|
||||||
|
|
||||||
|
package common
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gotest.tools/assert"
|
||||||
|
"sigs.k8s.io/cli-utils/pkg/kstatus/status"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSprintfWithColor(t *testing.T) {
|
||||||
|
testCases := map[string]struct {
|
||||||
|
color Color
|
||||||
|
format string
|
||||||
|
args []interface{}
|
||||||
|
expectedResult string
|
||||||
|
}{
|
||||||
|
"no args with color": {
|
||||||
|
color: GREEN,
|
||||||
|
format: "This is a test",
|
||||||
|
args: []interface{}{},
|
||||||
|
expectedResult: "\x1b[32mThis is a test\x1b[0m",
|
||||||
|
},
|
||||||
|
"with args and color": {
|
||||||
|
color: YELLOW,
|
||||||
|
format: "%s %s",
|
||||||
|
args: []interface{}{"sonic", "youth"},
|
||||||
|
expectedResult: "\x1b[33msonic youth\x1b[0m",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for tn, tc := range testCases {
|
||||||
|
t.Run(tn, func(t *testing.T) {
|
||||||
|
result := SprintfWithColor(tc.color, tc.format, tc.args...)
|
||||||
|
if want, got := tc.expectedResult, result; want != got {
|
||||||
|
t.Errorf("expected %q, but got %q", want, got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestColorForStatus(t *testing.T) {
|
||||||
|
testCases := map[string]struct {
|
||||||
|
status status.Status
|
||||||
|
expectedSetColor bool
|
||||||
|
expectedColor Color
|
||||||
|
}{
|
||||||
|
"status with color": {
|
||||||
|
status: status.CurrentStatus,
|
||||||
|
expectedSetColor: true,
|
||||||
|
expectedColor: GREEN,
|
||||||
|
},
|
||||||
|
"status without color": {
|
||||||
|
status: status.NotFoundStatus,
|
||||||
|
expectedSetColor: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for tn, tc := range testCases {
|
||||||
|
t.Run(tn, func(t *testing.T) {
|
||||||
|
color, setColor := ColorForStatus(tc.status)
|
||||||
|
assert.Equal(t, setColor, tc.expectedSetColor)
|
||||||
|
if tc.expectedSetColor {
|
||||||
|
assert.Equal(t, color, tc.expectedColor)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,152 @@
|
||||||
|
// Copyright 2020 The Kubernetes Authors.
|
||||||
|
// SPDX-License-Identifier: Apache-2.0
|
||||||
|
|
||||||
|
package table
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"strings"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
|
"k8s.io/cli-runtime/pkg/genericclioptions"
|
||||||
|
pe "sigs.k8s.io/cli-utils/pkg/kstatus/polling/event"
|
||||||
|
"sigs.k8s.io/cli-utils/pkg/object"
|
||||||
|
"sigs.k8s.io/cli-utils/pkg/print/common"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ColumnDefinition defines the columns that should be printed.
|
||||||
|
type ColumnDefinition interface {
|
||||||
|
Name() string
|
||||||
|
Header() string
|
||||||
|
Width() int
|
||||||
|
PrintResource(w io.Writer, width int, r Resource) (int, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResourceStates defines the interface that must be implemented
|
||||||
|
// by the object that provides information about the resources
|
||||||
|
// that should be printed.
|
||||||
|
type ResourceStates interface {
|
||||||
|
Resources() []Resource
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resource defines the interface that each of the Resource
|
||||||
|
// objects must implement.
|
||||||
|
type Resource interface {
|
||||||
|
Identifier() object.ObjMetadata
|
||||||
|
ResourceStatus() *pe.ResourceStatus
|
||||||
|
SubResources() []Resource
|
||||||
|
}
|
||||||
|
|
||||||
|
// BaseTablePrinter provides functionality for printing information
|
||||||
|
// about a set of resources into a table format.
|
||||||
|
// The printer will print to the Out stream defined in IOStreams,
|
||||||
|
// and will print into the format defined by the Column definitions.
|
||||||
|
type BaseTablePrinter struct {
|
||||||
|
IOStreams genericclioptions.IOStreams
|
||||||
|
Columns []ColumnDefinition
|
||||||
|
}
|
||||||
|
|
||||||
|
// PrintTable prints the resources defined in ResourceStates. It will
|
||||||
|
// print subresources if they exist.
|
||||||
|
// moveUpCount defines how many lines the printer should move up
|
||||||
|
// before starting printing. The return value is how many lines
|
||||||
|
// were printed.
|
||||||
|
func (t *BaseTablePrinter) PrintTable(rs ResourceStates,
|
||||||
|
moveUpCount int) int {
|
||||||
|
for i := 0; i < moveUpCount; i++ {
|
||||||
|
t.moveUp()
|
||||||
|
t.eraseCurrentLine()
|
||||||
|
}
|
||||||
|
linePrintCount := 0
|
||||||
|
|
||||||
|
for i, column := range t.Columns {
|
||||||
|
format := fmt.Sprintf("%%-%ds", column.Width())
|
||||||
|
t.printOrDie(format, column.Header())
|
||||||
|
if i == len(t.Columns)-1 {
|
||||||
|
t.printOrDie("\n")
|
||||||
|
linePrintCount++
|
||||||
|
} else {
|
||||||
|
t.printOrDie(" ")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, resource := range rs.Resources() {
|
||||||
|
for i, column := range t.Columns {
|
||||||
|
written, err := column.PrintResource(t.IOStreams.Out, column.Width(), resource)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
remainingSpace := column.Width() - written
|
||||||
|
t.printOrDie(strings.Repeat(" ", remainingSpace))
|
||||||
|
if i == len(t.Columns)-1 {
|
||||||
|
t.printOrDie("\n")
|
||||||
|
linePrintCount++
|
||||||
|
} else {
|
||||||
|
t.printOrDie(" ")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
linePrintCount += t.printSubTable(resource.SubResources(), "")
|
||||||
|
}
|
||||||
|
|
||||||
|
return linePrintCount
|
||||||
|
}
|
||||||
|
|
||||||
|
// printSubTable prints out any subresources that belong to the
|
||||||
|
// top-level resources. This function takes care of printing the correct tree
|
||||||
|
// structure and indentation.
|
||||||
|
func (t *BaseTablePrinter) printSubTable(resources []Resource,
|
||||||
|
prefix string) int {
|
||||||
|
linePrintCount := 0
|
||||||
|
for j, resource := range resources {
|
||||||
|
for i, column := range t.Columns {
|
||||||
|
availableWidth := column.Width()
|
||||||
|
if column.Name() == "resource" {
|
||||||
|
if j < len(resources)-1 {
|
||||||
|
t.printOrDie(prefix + `├─ `)
|
||||||
|
} else {
|
||||||
|
t.printOrDie(prefix + `└─ `)
|
||||||
|
}
|
||||||
|
availableWidth -= utf8.RuneCountInString(prefix) + 3
|
||||||
|
}
|
||||||
|
written, err := column.PrintResource(t.IOStreams.Out,
|
||||||
|
availableWidth, resource)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
remainingSpace := availableWidth - written
|
||||||
|
t.printOrDie(strings.Repeat(" ", remainingSpace))
|
||||||
|
if i == len(t.Columns)-1 {
|
||||||
|
t.printOrDie("\n")
|
||||||
|
linePrintCount++
|
||||||
|
} else {
|
||||||
|
t.printOrDie(" ")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var prefix string
|
||||||
|
if j < len(resources)-1 {
|
||||||
|
prefix = `│ `
|
||||||
|
} else {
|
||||||
|
prefix = " "
|
||||||
|
}
|
||||||
|
linePrintCount += t.printSubTable(resource.SubResources(), prefix)
|
||||||
|
}
|
||||||
|
return linePrintCount
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *BaseTablePrinter) printOrDie(format string, a ...interface{}) {
|
||||||
|
_, err := fmt.Fprintf(t.IOStreams.Out, format, a...)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *BaseTablePrinter) moveUp() {
|
||||||
|
t.printOrDie("%c[%dA", common.ESC, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *BaseTablePrinter) eraseCurrentLine() {
|
||||||
|
t.printOrDie("%c[2K\r", common.ESC)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,184 @@
|
||||||
|
// Copyright 2020 The Kubernetes Authors.
|
||||||
|
// SPDX-License-Identifier: Apache-2.0
|
||||||
|
|
||||||
|
package table
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gotest.tools/assert"
|
||||||
|
"k8s.io/apimachinery/pkg/runtime/schema"
|
||||||
|
"k8s.io/cli-runtime/pkg/genericclioptions"
|
||||||
|
pe "sigs.k8s.io/cli-utils/pkg/kstatus/polling/event"
|
||||||
|
"sigs.k8s.io/cli-utils/pkg/object"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
endColumnDef = ColumnDef{
|
||||||
|
ColumnName: "end",
|
||||||
|
ColumnHeader: "END",
|
||||||
|
ColumnWidth: 3,
|
||||||
|
PrintResourceFunc: func(w io.Writer, width int, r Resource) (i int,
|
||||||
|
err error) {
|
||||||
|
return fmt.Fprint(w, "end")
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestBaseTablePrinter_PrintTable(t *testing.T) {
|
||||||
|
testCases := map[string]struct {
|
||||||
|
columnDefinitions []ColumnDefinition
|
||||||
|
resources []Resource
|
||||||
|
expectedOutput string
|
||||||
|
}{
|
||||||
|
"no resources": {
|
||||||
|
columnDefinitions: []ColumnDefinition{
|
||||||
|
MustColumn("resource"),
|
||||||
|
endColumnDef,
|
||||||
|
},
|
||||||
|
resources: []Resource{},
|
||||||
|
expectedOutput: `
|
||||||
|
RESOURCE END
|
||||||
|
`,
|
||||||
|
},
|
||||||
|
"with resource": {
|
||||||
|
columnDefinitions: []ColumnDefinition{
|
||||||
|
MustColumn("resource"),
|
||||||
|
endColumnDef,
|
||||||
|
},
|
||||||
|
resources: []Resource{
|
||||||
|
&fakeResource{
|
||||||
|
resourceStatus: &pe.ResourceStatus{
|
||||||
|
Identifier: object.ObjMetadata{
|
||||||
|
Namespace: "default",
|
||||||
|
Name: "Foo",
|
||||||
|
GroupKind: schema.GroupKind{
|
||||||
|
Group: "apps",
|
||||||
|
Kind: "Deployment",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
expectedOutput: `
|
||||||
|
RESOURCE END
|
||||||
|
Deployment/Foo end
|
||||||
|
`,
|
||||||
|
},
|
||||||
|
"sub resources": {
|
||||||
|
columnDefinitions: []ColumnDefinition{
|
||||||
|
MustColumn("resource"),
|
||||||
|
endColumnDef,
|
||||||
|
},
|
||||||
|
resources: []Resource{
|
||||||
|
&fakeResource{
|
||||||
|
resourceStatus: &pe.ResourceStatus{
|
||||||
|
Identifier: object.ObjMetadata{
|
||||||
|
Namespace: "default",
|
||||||
|
Name: "Foo",
|
||||||
|
GroupKind: schema.GroupKind{
|
||||||
|
Group: "apps",
|
||||||
|
Kind: "Deployment",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
GeneratedResources: []*pe.ResourceStatus{
|
||||||
|
{
|
||||||
|
Identifier: object.ObjMetadata{
|
||||||
|
Namespace: "default",
|
||||||
|
Name: "Bar",
|
||||||
|
GroupKind: schema.GroupKind{
|
||||||
|
Group: "apps",
|
||||||
|
Kind: "ReplicaSet",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
expectedOutput: `
|
||||||
|
RESOURCE END
|
||||||
|
Deployment/Foo end
|
||||||
|
└─ ReplicaSet/Bar end
|
||||||
|
`,
|
||||||
|
},
|
||||||
|
"trim long content": {
|
||||||
|
columnDefinitions: []ColumnDefinition{
|
||||||
|
MustColumn("resource"),
|
||||||
|
endColumnDef,
|
||||||
|
},
|
||||||
|
resources: []Resource{
|
||||||
|
&fakeResource{
|
||||||
|
resourceStatus: &pe.ResourceStatus{
|
||||||
|
Identifier: object.ObjMetadata{
|
||||||
|
Namespace: "default",
|
||||||
|
Name: "VeryLongNameThatShouldBeTrimmed",
|
||||||
|
GroupKind: schema.GroupKind{
|
||||||
|
Group: "apps",
|
||||||
|
Kind: "Deployment",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
expectedOutput: `
|
||||||
|
RESOURCE END
|
||||||
|
Deployment/VeryLongNameThatShouldBeTrimm end
|
||||||
|
`,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for tn, tc := range testCases {
|
||||||
|
t.Run(tn, func(t *testing.T) {
|
||||||
|
ioStreams, _, outBuffer, _ := genericclioptions.NewTestIOStreams()
|
||||||
|
|
||||||
|
printer := &BaseTablePrinter{
|
||||||
|
IOStreams: ioStreams,
|
||||||
|
Columns: tc.columnDefinitions,
|
||||||
|
}
|
||||||
|
|
||||||
|
resourceStates := &fakeResourceStates{
|
||||||
|
resources: tc.resources,
|
||||||
|
}
|
||||||
|
|
||||||
|
printer.PrintTable(resourceStates, 0)
|
||||||
|
|
||||||
|
assert.Equal(t,
|
||||||
|
strings.TrimSpace(tc.expectedOutput),
|
||||||
|
strings.TrimSpace(outBuffer.String()))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type fakeResourceStates struct {
|
||||||
|
resources []Resource
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *fakeResourceStates) Resources() []Resource {
|
||||||
|
return r.resources
|
||||||
|
}
|
||||||
|
|
||||||
|
type fakeResource struct {
|
||||||
|
resourceStatus *pe.ResourceStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *fakeResource) Identifier() object.ObjMetadata {
|
||||||
|
return r.resourceStatus.Identifier
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *fakeResource) ResourceStatus() *pe.ResourceStatus {
|
||||||
|
return r.resourceStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *fakeResource) SubResources() []Resource {
|
||||||
|
var resources []Resource
|
||||||
|
for _, res := range r.resourceStatus.GeneratedResources {
|
||||||
|
resources = append(resources, &fakeResource{
|
||||||
|
resourceStatus: res,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return resources
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,243 @@
|
||||||
|
// Copyright 2020 The Kubernetes Authors.
|
||||||
|
// SPDX-License-Identifier: Apache-2.0
|
||||||
|
|
||||||
|
package table
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
||||||
|
"k8s.io/utils/integer"
|
||||||
|
"sigs.k8s.io/cli-utils/pkg/print/common"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ColumnDef is an implementation of the ColumnDefinition interface.
|
||||||
|
// It can be used to define simple columns that doesn't need additional
|
||||||
|
// knowledge about the actual type of the provided Resource besides the
|
||||||
|
// information available through the interface.
|
||||||
|
type ColumnDef struct {
|
||||||
|
ColumnName string
|
||||||
|
ColumnHeader string
|
||||||
|
ColumnWidth int
|
||||||
|
PrintResourceFunc func(w io.Writer, width int, r Resource) (int, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Name returns the name of the column.
|
||||||
|
func (c ColumnDef) Name() string {
|
||||||
|
return c.ColumnName
|
||||||
|
}
|
||||||
|
|
||||||
|
// Header returns the header that should be printed for
|
||||||
|
// the column.
|
||||||
|
func (c ColumnDef) Header() string {
|
||||||
|
return c.ColumnHeader
|
||||||
|
}
|
||||||
|
|
||||||
|
// Width returns the width of the column.
|
||||||
|
func (c ColumnDef) Width() int {
|
||||||
|
return c.ColumnWidth
|
||||||
|
}
|
||||||
|
|
||||||
|
// PrintResource is called by the BaseTablePrinter to output the
|
||||||
|
// content of a particular column. This implementation just delegates
|
||||||
|
// to the provided PrintResourceFunc.
|
||||||
|
func (c ColumnDef) PrintResource(w io.Writer, width int, r Resource) (int, error) {
|
||||||
|
return c.PrintResourceFunc(w, width, r)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MustColumn returns the pre-defined column definition with the
|
||||||
|
// provided name. If the name doesn't exist, it will panic.
|
||||||
|
func MustColumn(name string) ColumnDef {
|
||||||
|
c, found := columnDefinitions[name]
|
||||||
|
if !found {
|
||||||
|
panic(fmt.Errorf("unknown column name %q", name))
|
||||||
|
}
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
columnDefinitions = map[string]ColumnDef{
|
||||||
|
// namespace defines a column that output the namespace of the
|
||||||
|
// resource, or nothing in the case of clusterscoped resources.
|
||||||
|
"namespace": {
|
||||||
|
ColumnName: "namespace",
|
||||||
|
ColumnHeader: "NAMESPACE",
|
||||||
|
ColumnWidth: 10,
|
||||||
|
PrintResourceFunc: func(w io.Writer, width int, r Resource) (int,
|
||||||
|
error) {
|
||||||
|
namespace := r.Identifier().Namespace
|
||||||
|
if len(namespace) > width {
|
||||||
|
namespace = namespace[:width]
|
||||||
|
}
|
||||||
|
_, err := fmt.Fprint(w, namespace)
|
||||||
|
return len(namespace), err
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// resource defines a column that outputs the kind and name of a
|
||||||
|
// resource.
|
||||||
|
"resource": {
|
||||||
|
ColumnName: "resource",
|
||||||
|
ColumnHeader: "RESOURCE",
|
||||||
|
ColumnWidth: 40,
|
||||||
|
PrintResourceFunc: func(w io.Writer, width int, r Resource) (int,
|
||||||
|
error) {
|
||||||
|
text := fmt.Sprintf("%s/%s", r.Identifier().GroupKind.Kind,
|
||||||
|
r.Identifier().Name)
|
||||||
|
if len(text) > width {
|
||||||
|
text = text[:width]
|
||||||
|
}
|
||||||
|
_, err := fmt.Fprint(w, text)
|
||||||
|
return len(text), err
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// status defines a column that outputs the status of a resource. It
|
||||||
|
// will use ansii escape codes to color the output.
|
||||||
|
"status": {
|
||||||
|
ColumnName: "status",
|
||||||
|
ColumnHeader: "STATUS",
|
||||||
|
ColumnWidth: 10,
|
||||||
|
PrintResourceFunc: func(w io.Writer, width int, r Resource) (int,
|
||||||
|
error) {
|
||||||
|
rs := r.ResourceStatus()
|
||||||
|
if rs == nil {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
s := rs.Status.String()
|
||||||
|
if len(s) > width {
|
||||||
|
s = s[:width]
|
||||||
|
}
|
||||||
|
color, setColor := common.ColorForStatus(rs.Status)
|
||||||
|
var outputStatus string
|
||||||
|
if setColor {
|
||||||
|
outputStatus = common.SprintfWithColor(color, s)
|
||||||
|
} else {
|
||||||
|
outputStatus = s
|
||||||
|
}
|
||||||
|
_, err := fmt.Fprint(w, outputStatus)
|
||||||
|
return len(s), err
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// conditions defines a column that outputs the conditions for
|
||||||
|
// a resource. The output will be in colors.
|
||||||
|
"conditions": {
|
||||||
|
ColumnName: "conditions",
|
||||||
|
ColumnHeader: "CONDITIONS",
|
||||||
|
ColumnWidth: 40,
|
||||||
|
PrintResourceFunc: func(w io.Writer, width int, r Resource) (int,
|
||||||
|
error) {
|
||||||
|
rs := r.ResourceStatus()
|
||||||
|
if rs == nil {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
u := rs.Resource
|
||||||
|
if u == nil {
|
||||||
|
return fmt.Fprintf(w, "-")
|
||||||
|
}
|
||||||
|
|
||||||
|
conditions, found, err := unstructured.NestedSlice(u.Object,
|
||||||
|
"status", "conditions")
|
||||||
|
if !found || err != nil || len(conditions) == 0 {
|
||||||
|
return fmt.Fprintf(w, "<None>")
|
||||||
|
}
|
||||||
|
|
||||||
|
realLength := 0
|
||||||
|
for i, cond := range conditions {
|
||||||
|
condition := cond.(map[string]interface{})
|
||||||
|
conditionType := condition["type"].(string)
|
||||||
|
conditionStatus := condition["status"].(string)
|
||||||
|
var color common.Color
|
||||||
|
switch conditionStatus {
|
||||||
|
case "True":
|
||||||
|
color = common.GREEN
|
||||||
|
case "False":
|
||||||
|
color = common.RED
|
||||||
|
default:
|
||||||
|
color = common.YELLOW
|
||||||
|
}
|
||||||
|
remainingWidth := width - realLength
|
||||||
|
if len(conditionType) > remainingWidth {
|
||||||
|
conditionType = conditionType[:remainingWidth]
|
||||||
|
}
|
||||||
|
_, err := fmt.Fprint(w, common.SprintfWithColor(color, conditionType))
|
||||||
|
if err != nil {
|
||||||
|
return realLength, err
|
||||||
|
}
|
||||||
|
realLength += len(conditionType)
|
||||||
|
if i < len(conditions)-1 && width-realLength > 2 {
|
||||||
|
_, err = fmt.Fprintf(w, ",")
|
||||||
|
if err != nil {
|
||||||
|
return realLength, err
|
||||||
|
}
|
||||||
|
realLength += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return realLength, nil
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// age defines a column that outputs the age of a resource computed
|
||||||
|
// by looking at the creationTimestamp field.
|
||||||
|
"age": {
|
||||||
|
ColumnName: "age",
|
||||||
|
ColumnHeader: "AGE",
|
||||||
|
ColumnWidth: 6,
|
||||||
|
PrintResourceFunc: func(w io.Writer, width int, r Resource) (i int, err error) {
|
||||||
|
rs := r.ResourceStatus()
|
||||||
|
if rs == nil {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
u := rs.Resource
|
||||||
|
if u == nil {
|
||||||
|
return fmt.Fprint(w, "-")
|
||||||
|
}
|
||||||
|
|
||||||
|
timestamp, found, err := unstructured.NestedString(u.Object,
|
||||||
|
"metadata", "creationTimestamp")
|
||||||
|
if !found || err != nil || timestamp == "" {
|
||||||
|
return fmt.Fprint(w, "-")
|
||||||
|
}
|
||||||
|
parsedTime, err := time.Parse(time.RFC3339, timestamp)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Fprint(w, "-")
|
||||||
|
}
|
||||||
|
age := time.Since(parsedTime)
|
||||||
|
switch {
|
||||||
|
case age.Seconds() <= 90:
|
||||||
|
return fmt.Fprintf(w, "%ds",
|
||||||
|
integer.RoundToInt32(age.Round(time.Second).Seconds()))
|
||||||
|
case age.Minutes() <= 90:
|
||||||
|
return fmt.Fprintf(w, "%dm",
|
||||||
|
integer.RoundToInt32(age.Round(time.Minute).Minutes()))
|
||||||
|
default:
|
||||||
|
return fmt.Fprintf(w, "%dh",
|
||||||
|
integer.RoundToInt32(age.Round(time.Hour).Hours()))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// message defines a column that outputs the message from a
|
||||||
|
// ResourceStatus, or if there is a non-nil error, output the text
|
||||||
|
// from the error instead.
|
||||||
|
"message": {
|
||||||
|
ColumnName: "message",
|
||||||
|
ColumnHeader: "MESSAGE",
|
||||||
|
ColumnWidth: 40,
|
||||||
|
PrintResourceFunc: func(w io.Writer, width int, r Resource) (i int, err error) {
|
||||||
|
rs := r.ResourceStatus()
|
||||||
|
if rs == nil {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
var message string
|
||||||
|
if rs.Error != nil {
|
||||||
|
message = rs.Error.Error()
|
||||||
|
} else {
|
||||||
|
message = rs.Message
|
||||||
|
}
|
||||||
|
if len(message) > width {
|
||||||
|
message = message[:width]
|
||||||
|
}
|
||||||
|
return fmt.Fprint(w, message)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,239 @@
|
||||||
|
// Copyright 2020 The Kubernetes Authors.
|
||||||
|
// SPDX-License-Identifier: Apache-2.0
|
||||||
|
|
||||||
|
package table
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"fmt"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
v1 "k8s.io/api/core/v1"
|
||||||
|
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||||
|
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
||||||
|
"k8s.io/apimachinery/pkg/runtime/schema"
|
||||||
|
pe "sigs.k8s.io/cli-utils/pkg/kstatus/polling/event"
|
||||||
|
"sigs.k8s.io/cli-utils/pkg/kstatus/status"
|
||||||
|
"sigs.k8s.io/cli-utils/pkg/object"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestColumnDefs(t *testing.T) {
|
||||||
|
testCases := map[string]struct {
|
||||||
|
columnName string
|
||||||
|
resource Resource
|
||||||
|
columnWidth int
|
||||||
|
expectedOutput string
|
||||||
|
}{
|
||||||
|
"namespace": {
|
||||||
|
columnName: "namespace",
|
||||||
|
resource: &fakeResource{
|
||||||
|
resourceStatus: &pe.ResourceStatus{
|
||||||
|
Identifier: object.ObjMetadata{
|
||||||
|
Namespace: "Foo",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
columnWidth: 10,
|
||||||
|
expectedOutput: "Foo",
|
||||||
|
},
|
||||||
|
"namespace trimmed": {
|
||||||
|
columnName: "namespace",
|
||||||
|
resource: &fakeResource{
|
||||||
|
resourceStatus: &pe.ResourceStatus{
|
||||||
|
Identifier: object.ObjMetadata{
|
||||||
|
Namespace: "ICanHearTheHeartBeatingAsOne",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
columnWidth: 10,
|
||||||
|
expectedOutput: "ICanHearTh",
|
||||||
|
},
|
||||||
|
"resource": {
|
||||||
|
columnName: "resource",
|
||||||
|
resource: &fakeResource{
|
||||||
|
resourceStatus: &pe.ResourceStatus{
|
||||||
|
Identifier: object.ObjMetadata{
|
||||||
|
Name: "YoLaTengo",
|
||||||
|
GroupKind: schema.GroupKind{
|
||||||
|
Kind: "RoleBinding",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
columnWidth: 40,
|
||||||
|
expectedOutput: "RoleBinding/YoLaTengo",
|
||||||
|
},
|
||||||
|
"resource trimmed": {
|
||||||
|
columnName: "resource",
|
||||||
|
resource: &fakeResource{
|
||||||
|
resourceStatus: &pe.ResourceStatus{
|
||||||
|
Identifier: object.ObjMetadata{
|
||||||
|
Name: "SlantedAndEnchanted",
|
||||||
|
GroupKind: schema.GroupKind{
|
||||||
|
Kind: "Pavement",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
columnWidth: 25,
|
||||||
|
expectedOutput: "Pavement/SlantedAndEnchan",
|
||||||
|
},
|
||||||
|
"status with color": {
|
||||||
|
columnName: "status",
|
||||||
|
resource: &fakeResource{
|
||||||
|
resourceStatus: &pe.ResourceStatus{
|
||||||
|
Status: status.CurrentStatus,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
columnWidth: 10,
|
||||||
|
expectedOutput: "\x1b[32mCurrent\x1b[0m",
|
||||||
|
},
|
||||||
|
"status trimmed": {
|
||||||
|
columnName: "status",
|
||||||
|
resource: &fakeResource{
|
||||||
|
resourceStatus: &pe.ResourceStatus{
|
||||||
|
Status: status.NotFoundStatus,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
columnWidth: 5,
|
||||||
|
expectedOutput: "NotFo",
|
||||||
|
},
|
||||||
|
"conditions with color": {
|
||||||
|
columnName: "conditions",
|
||||||
|
resource: &fakeResource{
|
||||||
|
resourceStatus: &pe.ResourceStatus{
|
||||||
|
Resource: mustResourceWithConditions([]condition{
|
||||||
|
{
|
||||||
|
Type: "Ready",
|
||||||
|
Status: v1.ConditionUnknown,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
columnWidth: 10,
|
||||||
|
expectedOutput: "\x1b[33mReady\x1b[0m",
|
||||||
|
},
|
||||||
|
"conditions trimmed": {
|
||||||
|
columnName: "conditions",
|
||||||
|
resource: &fakeResource{
|
||||||
|
resourceStatus: &pe.ResourceStatus{
|
||||||
|
Resource: mustResourceWithConditions([]condition{
|
||||||
|
{
|
||||||
|
Type: "Ready",
|
||||||
|
Status: v1.ConditionTrue,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Type: "Reconciling",
|
||||||
|
Status: v1.ConditionFalse,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
columnWidth: 10,
|
||||||
|
expectedOutput: "\x1b[32mReady\x1b[0m,\x1b[31mReco\x1b[0m",
|
||||||
|
},
|
||||||
|
"age not found": {
|
||||||
|
columnName: "age",
|
||||||
|
resource: &fakeResource{
|
||||||
|
resourceStatus: &pe.ResourceStatus{
|
||||||
|
Resource: &unstructured.Unstructured{},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
columnWidth: 10,
|
||||||
|
expectedOutput: "-",
|
||||||
|
},
|
||||||
|
"age": {
|
||||||
|
columnName: "age",
|
||||||
|
resource: &fakeResource{
|
||||||
|
resourceStatus: &pe.ResourceStatus{
|
||||||
|
Resource: mustResourceWithCreationTimestamp(45 * time.Minute),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
columnWidth: 10,
|
||||||
|
expectedOutput: "45m",
|
||||||
|
},
|
||||||
|
"message without error": {
|
||||||
|
columnName: "message",
|
||||||
|
resource: &fakeResource{
|
||||||
|
resourceStatus: &pe.ResourceStatus{
|
||||||
|
Message: "this is a test",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
columnWidth: 30,
|
||||||
|
expectedOutput: "this is a test",
|
||||||
|
},
|
||||||
|
"message from error": {
|
||||||
|
columnName: "message",
|
||||||
|
resource: &fakeResource{
|
||||||
|
resourceStatus: &pe.ResourceStatus{
|
||||||
|
Message: "this is a test",
|
||||||
|
Error: fmt.Errorf("something went wrong somewhere"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
columnWidth: 50,
|
||||||
|
expectedOutput: "something went wrong somewhere",
|
||||||
|
},
|
||||||
|
"message trimmed": {
|
||||||
|
columnName: "message",
|
||||||
|
resource: &fakeResource{
|
||||||
|
resourceStatus: &pe.ResourceStatus{
|
||||||
|
Message: "this is a test",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
columnWidth: 6,
|
||||||
|
expectedOutput: "this i",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for tn, tc := range testCases {
|
||||||
|
t.Run(tn, func(t *testing.T) {
|
||||||
|
columnDef := MustColumn(tc.columnName)
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
_, err := columnDef.PrintResource(&buf, tc.columnWidth, tc.resource)
|
||||||
|
if err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if want, got := tc.expectedOutput, buf.String(); want != got {
|
||||||
|
t.Errorf("expected %q, but got %q", want, got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type condition struct {
|
||||||
|
Type string
|
||||||
|
Status v1.ConditionStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
func mustResourceWithConditions(conditions []condition) *unstructured.Unstructured {
|
||||||
|
u := &unstructured.Unstructured{
|
||||||
|
Object: make(map[string]interface{}),
|
||||||
|
}
|
||||||
|
var conditionsSlice []interface{}
|
||||||
|
for _, c := range conditions {
|
||||||
|
cond := make(map[string]interface{})
|
||||||
|
cond["type"] = c.Type
|
||||||
|
cond["status"] = string(c.Status)
|
||||||
|
conditionsSlice = append(conditionsSlice, cond)
|
||||||
|
}
|
||||||
|
err := unstructured.SetNestedSlice(u.Object, conditionsSlice,
|
||||||
|
"status", "conditions")
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
return u
|
||||||
|
}
|
||||||
|
|
||||||
|
func mustResourceWithCreationTimestamp(age time.Duration) *unstructured.Unstructured {
|
||||||
|
u := &unstructured.Unstructured{
|
||||||
|
Object: make(map[string]interface{}),
|
||||||
|
}
|
||||||
|
creationTime := time.Now().Add(-age)
|
||||||
|
u.SetCreationTimestamp(metav1.Time{
|
||||||
|
Time: creationTime,
|
||||||
|
})
|
||||||
|
return u
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue