diff --git a/cli/cliui/output.go b/cli/cliui/output.go new file mode 100644 index 0000000000000..e537e30473da1 --- /dev/null +++ b/cli/cliui/output.go @@ -0,0 +1,156 @@ +package cliui + +import ( + "context" + "encoding/json" + "reflect" + "strings" + + "github.com/spf13/cobra" + "golang.org/x/xerrors" +) + +type OutputFormat interface { + ID() string + AttachFlags(cmd *cobra.Command) + Format(ctx context.Context, data any) (string, error) +} + +type OutputFormatter struct { + formats []OutputFormat + formatID string +} + +// NewOutputFormatter creates a new OutputFormatter with the given formats. The +// first format is the default format. At least two formats must be provided. +func NewOutputFormatter(formats ...OutputFormat) *OutputFormatter { + if len(formats) < 2 { + panic("at least two output formats must be provided") + } + + formatIDs := make(map[string]struct{}, len(formats)) + for _, format := range formats { + if format.ID() == "" { + panic("output format ID must not be empty") + } + if _, ok := formatIDs[format.ID()]; ok { + panic("duplicate format ID: " + format.ID()) + } + formatIDs[format.ID()] = struct{}{} + } + + return &OutputFormatter{ + formats: formats, + formatID: formats[0].ID(), + } +} + +// AttachFlags attaches the --output flag to the given command, and any +// additional flags required by the output formatters. +func (f *OutputFormatter) AttachFlags(cmd *cobra.Command) { + for _, format := range f.formats { + format.AttachFlags(cmd) + } + + formatNames := make([]string, 0, len(f.formats)) + for _, format := range f.formats { + formatNames = append(formatNames, format.ID()) + } + + cmd.Flags().StringVarP(&f.formatID, "output", "o", f.formats[0].ID(), "Output format. Available formats: "+strings.Join(formatNames, ", ")) +} + +// Format formats the given data using the format specified by the --output +// flag. If the flag is not set, the default format is used. +func (f *OutputFormatter) Format(ctx context.Context, data any) (string, error) { + for _, format := range f.formats { + if format.ID() == f.formatID { + return format.Format(ctx, data) + } + } + + return "", xerrors.Errorf("unknown output format %q", f.formatID) +} + +type tableFormat struct { + defaultColumns []string + allColumns []string + sort string + + columns []string +} + +var _ OutputFormat = &tableFormat{} + +// TableFormat creates a table formatter for the given output type. The output +// type should be specified as an empty slice of the desired type. +// +// E.g.: TableFormat([]MyType{}, []string{"foo", "bar"}) +// +// defaultColumns is optional and specifies the default columns to display. If +// not specified, all columns are displayed by default. +func TableFormat(out any, defaultColumns []string) OutputFormat { + v := reflect.Indirect(reflect.ValueOf(out)) + if v.Kind() != reflect.Slice { + panic("DisplayTable called with a non-slice type") + } + + // Get the list of table column headers. + headers, defaultSort, err := typeToTableHeaders(v.Type().Elem()) + if err != nil { + panic("parse table headers: " + err.Error()) + } + + tf := &tableFormat{ + defaultColumns: headers, + allColumns: headers, + sort: defaultSort, + } + if len(defaultColumns) > 0 { + tf.defaultColumns = defaultColumns + } + + return tf +} + +// ID implements OutputFormat. +func (*tableFormat) ID() string { + return "table" +} + +// AttachFlags implements OutputFormat. +func (f *tableFormat) AttachFlags(cmd *cobra.Command) { + cmd.Flags().StringSliceVarP(&f.columns, "column", "c", f.defaultColumns, "Columns to display in table output. Available columns: "+strings.Join(f.allColumns, ", ")) +} + +// Format implements OutputFormat. +func (f *tableFormat) Format(_ context.Context, data any) (string, error) { + return DisplayTable(data, f.sort, f.columns) +} + +type jsonFormat struct{} + +var _ OutputFormat = jsonFormat{} + +// JSONFormat creates a JSON formatter. +func JSONFormat() OutputFormat { + return jsonFormat{} +} + +// ID implements OutputFormat. +func (jsonFormat) ID() string { + return "json" +} + +// AttachFlags implements OutputFormat. +func (jsonFormat) AttachFlags(_ *cobra.Command) {} + +// Format implements OutputFormat. +func (jsonFormat) Format(_ context.Context, data any) (string, error) { + outBytes, err := json.MarshalIndent(data, "", " ") + if err != nil { + return "", xerrors.Errorf("marshal output to JSON: %w", err) + } + + return string(outBytes), nil +} diff --git a/cli/cliui/output_test.go b/cli/cliui/output_test.go new file mode 100644 index 0000000000000..7a31df9ab8afd --- /dev/null +++ b/cli/cliui/output_test.go @@ -0,0 +1,128 @@ +package cliui_test + +import ( + "context" + "encoding/json" + "sync/atomic" + "testing" + + "github.com/spf13/cobra" + "github.com/stretchr/testify/require" + + "github.com/coder/coder/cli/cliui" +) + +type format struct { + id string + attachFlagsFn func(cmd *cobra.Command) + formatFn func(ctx context.Context, data any) (string, error) +} + +var _ cliui.OutputFormat = &format{} + +func (f *format) ID() string { + return f.id +} + +func (f *format) AttachFlags(cmd *cobra.Command) { + if f.attachFlagsFn != nil { + f.attachFlagsFn(cmd) + } +} + +func (f *format) Format(ctx context.Context, data any) (string, error) { + if f.formatFn != nil { + return f.formatFn(ctx, data) + } + + return "", nil +} + +func Test_OutputFormatter(t *testing.T) { + t.Parallel() + + t.Run("RequiresTwoFormatters", func(t *testing.T) { + t.Parallel() + + require.Panics(t, func() { + cliui.NewOutputFormatter() + }) + require.Panics(t, func() { + cliui.NewOutputFormatter(cliui.JSONFormat()) + }) + }) + + t.Run("NoMissingFormatID", func(t *testing.T) { + t.Parallel() + + require.Panics(t, func() { + cliui.NewOutputFormatter( + cliui.JSONFormat(), + &format{id: ""}, + ) + }) + }) + + t.Run("NoDuplicateFormats", func(t *testing.T) { + t.Parallel() + + require.Panics(t, func() { + cliui.NewOutputFormatter( + cliui.JSONFormat(), + cliui.JSONFormat(), + ) + }) + }) + + t.Run("OK", func(t *testing.T) { + t.Parallel() + + var called int64 + f := cliui.NewOutputFormatter( + cliui.JSONFormat(), + &format{ + id: "foo", + attachFlagsFn: func(cmd *cobra.Command) { + cmd.Flags().StringP("foo", "f", "", "foo flag 1234") + }, + formatFn: func(_ context.Context, _ any) (string, error) { + atomic.AddInt64(&called, 1) + return "foo", nil + }, + }, + ) + + cmd := &cobra.Command{} + f.AttachFlags(cmd) + + selected, err := cmd.Flags().GetString("output") + require.NoError(t, err) + require.Equal(t, "json", selected) + usage := cmd.Flags().FlagUsages() + require.Contains(t, usage, "Available formats: json, foo") + require.Contains(t, usage, "foo flag 1234") + + ctx := context.Background() + data := []string{"hi", "dean", "was", "here"} + out, err := f.Format(ctx, data) + require.NoError(t, err) + + var got []string + require.NoError(t, json.Unmarshal([]byte(out), &got)) + require.Equal(t, data, got) + require.EqualValues(t, 0, atomic.LoadInt64(&called)) + + require.NoError(t, cmd.Flags().Set("output", "foo")) + out, err = f.Format(ctx, data) + require.NoError(t, err) + require.Equal(t, "foo", out) + require.EqualValues(t, 1, atomic.LoadInt64(&called)) + + require.NoError(t, cmd.Flags().Set("output", "bar")) + out, err = f.Format(ctx, data) + require.Error(t, err) + require.ErrorContains(t, err, "bar") + require.Equal(t, "", out) + require.EqualValues(t, 1, atomic.LoadInt64(&called)) + }) +} diff --git a/cli/cliui/table.go b/cli/cliui/table.go index cd417641f6892..b7ed7a2dff2e6 100644 --- a/cli/cliui/table.go +++ b/cli/cliui/table.go @@ -22,10 +22,10 @@ func Table() table.Writer { return tableWriter } -// FilterTableColumns returns configurations to hide columns +// filterTableColumns returns configurations to hide columns // that are not provided in the array. If the array is empty, // no filtering will occur! -func FilterTableColumns(header table.Row, columns []string) []table.ColumnConfig { +func filterTableColumns(header table.Row, columns []string) []table.ColumnConfig { if len(columns) == 0 { return nil } @@ -51,6 +51,9 @@ func FilterTableColumns(header table.Row, columns []string) []table.ColumnConfig // of structs. At least one field in the struct must have a `table:""` tag // containing the name of the column in the outputted table. // +// If `sort` is not specified, the field with the `table:"$NAME,default_sort"` +// tag will be used to sort. An error will be returned if no field has this tag. +// // Nested structs are processed if the field has the `table:"$NAME,recursive"` // tag and their fields will be named as `$PARENT_NAME $NAME`. If the tag is // malformed or a field is marked as recursive but does not contain a struct or @@ -67,13 +70,16 @@ func DisplayTable(out any, sort string, filterColumns []string) (string, error) } // Get the list of table column headers. - headersRaw, err := typeToTableHeaders(v.Type().Elem()) + headersRaw, defaultSort, err := typeToTableHeaders(v.Type().Elem()) if err != nil { return "", xerrors.Errorf("get table headers recursively for type %q: %w", v.Type().Elem().String(), err) } if len(headersRaw) == 0 { return "", xerrors.New(`no table headers found on the input type, make sure there is at least one "table" struct tag`) } + if sort == "" { + sort = defaultSort + } headers := make(table.Row, len(headersRaw)) for i, header := range headersRaw { headers[i] = header @@ -101,7 +107,7 @@ func DisplayTable(out any, sort string, filterColumns []string) (string, error) column := strings.ToLower(strings.ReplaceAll(column, "_", " ")) h, ok := headersMap[column] if !ok { - return "", xerrors.Errorf(`specified filter column %q not found in table headers, available columns are "%v"`, sort, strings.Join(headersRaw, `", "`)) + return "", xerrors.Errorf(`specified filter column %q not found in table headers, available columns are "%v"`, column, strings.Join(headersRaw, `", "`)) } // Autocorrect @@ -128,7 +134,7 @@ func DisplayTable(out any, sort string, filterColumns []string) (string, error) // Setup the table formatter. tw := Table() tw.AppendHeader(headers) - tw.SetColumnConfigs(FilterTableColumns(headers, filterColumns)) + tw.SetColumnConfigs(filterTableColumns(headers, filterColumns)) if sort != "" { tw.SortBy([]table.SortBy{{ Name: sort, @@ -182,29 +188,32 @@ func DisplayTable(out any, sort string, filterColumns []string) (string, error) // returned. If the table tag is malformed, an error is returned. // // The returned name is transformed from "snake_case" to "normal text". -func parseTableStructTag(field reflect.StructField) (name string, recurse bool, err error) { +func parseTableStructTag(field reflect.StructField) (name string, defaultSort, recursive bool, err error) { tags, err := structtag.Parse(string(field.Tag)) if err != nil { - return "", false, xerrors.Errorf("parse struct field tag %q: %w", string(field.Tag), err) + return "", false, false, xerrors.Errorf("parse struct field tag %q: %w", string(field.Tag), err) } tag, err := tags.Get("table") if err != nil || tag.Name == "-" { // tags.Get only returns an error if the tag is not found. - return "", false, nil + return "", false, false, nil } - recursive := false + defaultSortOpt := false + recursiveOpt := false for _, opt := range tag.Options { - if opt == "recursive" { - recursive = true - continue + switch opt { + case "default_sort": + defaultSortOpt = true + case "recursive": + recursiveOpt = true + default: + return "", false, false, xerrors.Errorf("unknown option %q in struct field tag", opt) } - - return "", false, xerrors.Errorf("unknown option %q in struct field tag", opt) } - return strings.ReplaceAll(tag.Name, "_", " "), recursive, nil + return strings.ReplaceAll(tag.Name, "_", " "), defaultSortOpt, recursiveOpt, nil } func isStructOrStructPointer(t reflect.Type) bool { @@ -214,34 +223,41 @@ func isStructOrStructPointer(t reflect.Type) bool { // typeToTableHeaders converts a type to a slice of column names. If the given // type is invalid (not a struct or a pointer to a struct, has invalid table // tags, etc.), an error is returned. -func typeToTableHeaders(t reflect.Type) ([]string, error) { +func typeToTableHeaders(t reflect.Type) ([]string, string, error) { if !isStructOrStructPointer(t) { - return nil, xerrors.Errorf("typeToTableHeaders called with a non-struct or a non-pointer-to-a-struct type") + return nil, "", xerrors.Errorf("typeToTableHeaders called with a non-struct or a non-pointer-to-a-struct type") } if t.Kind() == reflect.Pointer { t = t.Elem() } headers := []string{} + defaultSortName := "" for i := 0; i < t.NumField(); i++ { field := t.Field(i) - name, recursive, err := parseTableStructTag(field) + name, defaultSort, recursive, err := parseTableStructTag(field) if err != nil { - return nil, xerrors.Errorf("parse struct tags for field %q in type %q: %w", field.Name, t.String(), err) + return nil, "", xerrors.Errorf("parse struct tags for field %q in type %q: %w", field.Name, t.String(), err) } if name == "" { continue } + if defaultSort { + if defaultSortName != "" { + return nil, "", xerrors.Errorf("multiple fields marked as default sort in type %q", t.String()) + } + defaultSortName = name + } fieldType := field.Type if recursive { if !isStructOrStructPointer(fieldType) { - return nil, xerrors.Errorf("field %q in type %q is marked as recursive but does not contain a struct or a pointer to a struct", field.Name, t.String()) + return nil, "", xerrors.Errorf("field %q in type %q is marked as recursive but does not contain a struct or a pointer to a struct", field.Name, t.String()) } - childNames, err := typeToTableHeaders(fieldType) + childNames, _, err := typeToTableHeaders(fieldType) if err != nil { - return nil, xerrors.Errorf("get child field header names for field %q in type %q: %w", field.Name, fieldType.String(), err) + return nil, "", xerrors.Errorf("get child field header names for field %q in type %q: %w", field.Name, fieldType.String(), err) } for _, childName := range childNames { headers = append(headers, fmt.Sprintf("%s %s", name, childName)) @@ -252,7 +268,11 @@ func typeToTableHeaders(t reflect.Type) ([]string, error) { headers = append(headers, name) } - return headers, nil + if defaultSortName == "" { + return nil, "", xerrors.Errorf("no field marked as default_sort in type %q", t.String()) + } + + return headers, defaultSortName, nil } // valueToTableMap converts a struct to a map of column name to value. If the @@ -276,7 +296,7 @@ func valueToTableMap(val reflect.Value) (map[string]any, error) { for i := 0; i < val.NumField(); i++ { field := val.Type().Field(i) fieldVal := val.Field(i) - name, recursive, err := parseTableStructTag(field) + name, _, recursive, err := parseTableStructTag(field) if err != nil { return nil, xerrors.Errorf("parse struct tags for field %q in type %T: %w", field.Name, val, err) } @@ -309,18 +329,3 @@ func valueToTableMap(val reflect.Value) (map[string]any, error) { return row, nil } - -// TableHeaders returns the table header names of all -// fields in tSlice. tSlice must be a slice of some type. -func TableHeaders(tSlice any) ([]string, error) { - v := reflect.Indirect(reflect.ValueOf(tSlice)) - rawHeaders, err := typeToTableHeaders(v.Type().Elem()) - if err != nil { - return nil, xerrors.Errorf("type to table headers: %w", err) - } - out := make([]string, 0, len(rawHeaders)) - for _, hdr := range rawHeaders { - out = append(out, strings.Replace(hdr, " ", "_", -1)) - } - return out, nil -} diff --git a/cli/cliui/table_test.go b/cli/cliui/table_test.go index a8e87da7d6b9c..249e3f00c35c1 100644 --- a/cli/cliui/table_test.go +++ b/cli/cliui/table_test.go @@ -24,7 +24,7 @@ func (s stringWrapper) String() string { } type tableTest1 struct { - Name string `table:"name"` + Name string `table:"name,default_sort"` NotIncluded string // no table tag Age int `table:"age"` Roles []string `table:"roles"` @@ -39,21 +39,45 @@ type tableTest1 struct { } type tableTest2 struct { - Name stringWrapper `table:"name"` + Name stringWrapper `table:"name,default_sort"` Age int `table:"age"` NotIncluded string `table:"-"` } type tableTest3 struct { NotIncluded string // no table tag - Sub tableTest2 `table:"inner,recursive"` + Sub tableTest2 `table:"inner,recursive,default_sort"` } func Test_DisplayTable(t *testing.T) { t.Parallel() someTime := time.Date(2022, 8, 2, 15, 49, 10, 0, time.UTC) + + // Not sorted by name or age to test sorting. in := []tableTest1{ + { + Name: "bar", + Age: 20, + Roles: []string{"a"}, + Sub1: tableTest2{ + Name: stringWrapper{str: "bar1"}, + Age: 21, + }, + Sub2: nil, + Sub3: tableTest3{ + Sub: tableTest2{ + Name: stringWrapper{str: "bar3"}, + Age: 23, + }, + }, + Sub4: tableTest2{ + Name: stringWrapper{str: "bar4"}, + Age: 24, + }, + Time: someTime, + TimePtr: nil, + }, { Name: "foo", Age: 10, @@ -79,28 +103,6 @@ func Test_DisplayTable(t *testing.T) { Time: someTime, TimePtr: &someTime, }, - { - Name: "bar", - Age: 20, - Roles: []string{"a"}, - Sub1: tableTest2{ - Name: stringWrapper{str: "bar1"}, - Age: 21, - }, - Sub2: nil, - Sub3: tableTest3{ - Sub: tableTest2{ - Name: stringWrapper{str: "bar3"}, - Age: 23, - }, - }, - Sub4: tableTest2{ - Name: stringWrapper{str: "bar4"}, - Age: 24, - }, - Time: someTime, - TimePtr: nil, - }, { Name: "baz", Age: 30, @@ -132,9 +134,9 @@ func Test_DisplayTable(t *testing.T) { expected := ` NAME AGE ROLES SUB 1 NAME SUB 1 AGE SUB 2 NAME SUB 2 AGE SUB 3 INNER NAME SUB 3 INNER AGE SUB 4 TIME TIME PTR -foo 10 [a b c] foo1 11 foo2 12 foo3 13 {foo4 14 } 2022-08-02T15:49:10Z 2022-08-02T15:49:10Z bar 20 [a] bar1 21 bar3 23 {bar4 24 } 2022-08-02T15:49:10Z baz 30 [] baz1 31 baz3 33 {baz4 34 } 2022-08-02T15:49:10Z +foo 10 [a b c] foo1 11 foo2 12 foo3 13 {foo4 14 } 2022-08-02T15:49:10Z 2022-08-02T15:49:10Z ` // Test with non-pointer values. @@ -154,17 +156,17 @@ baz 30 [] baz1 31 baz3 compareTables(t, expected, out) }) - t.Run("Sort", func(t *testing.T) { + t.Run("CustomSort", func(t *testing.T) { t.Parallel() expected := ` NAME AGE ROLES SUB 1 NAME SUB 1 AGE SUB 2 NAME SUB 2 AGE SUB 3 INNER NAME SUB 3 INNER AGE SUB 4 TIME TIME PTR +foo 10 [a b c] foo1 11 foo2 12 foo3 13 {foo4 14 } 2022-08-02T15:49:10Z 2022-08-02T15:49:10Z bar 20 [a] bar1 21 bar3 23 {bar4 24 } 2022-08-02T15:49:10Z baz 30 [] baz1 31 baz3 33 {baz4 34 } 2022-08-02T15:49:10Z -foo 10 [a b c] foo1 11 foo2 12 foo3 13 {foo4 14 } 2022-08-02T15:49:10Z 2022-08-02T15:49:10Z ` - out, err := cliui.DisplayTable(in, "name", nil) + out, err := cliui.DisplayTable(in, "age", nil) log.Println("rendered table:\n" + out) require.NoError(t, err) compareTables(t, expected, out) @@ -175,9 +177,9 @@ foo 10 [a b c] foo1 11 foo2 12 foo3 expected := ` NAME SUB 1 NAME SUB 3 INNER NAME TIME -foo foo1 foo3 2022-08-02T15:49:10Z bar bar1 bar3 2022-08-02T15:49:10Z baz baz1 baz3 2022-08-02T15:49:10Z +foo foo1 foo3 2022-08-02T15:49:10Z ` out, err := cliui.DisplayTable(in, "", []string{"name", "sub_1_name", "sub_3 inner name", "time"}) @@ -327,28 +329,6 @@ baz baz1 baz3 2022-08-02T15:49:10Z }) } -func Test_TableHeaders(t *testing.T) { - t.Parallel() - s := []tableTest1{} - expectedFields := []string{ - "name", - "age", - "roles", - "sub_1_name", - "sub_1_age", - "sub_2_name", - "sub_2_age", - "sub_3_inner_name", - "sub_3_inner_age", - "sub_4", - "time", - "time_ptr", - } - headers, err := cliui.TableHeaders(s) - require.NoError(t, err) - require.EqualValues(t, expectedFields, headers) -} - // compareTables normalizes the incoming table lines func compareTables(t *testing.T, expected, out string) { t.Helper() diff --git a/cli/list.go b/cli/list.go index a198dd25b86b6..510589eff64d0 100644 --- a/cli/list.go +++ b/cli/list.go @@ -2,7 +2,6 @@ package cli import ( "fmt" - "strings" "time" "github.com/google/uuid" @@ -14,14 +13,21 @@ import ( "github.com/coder/coder/codersdk" ) +// workspaceListRow is the type provided to the OutputFormatter. This is a bit +// dodgy but it's the only way to do complex display code for one format vs. the +// other. type workspaceListRow struct { - Workspace string `table:"workspace"` - Template string `table:"template"` - Status string `table:"status"` - LastBuilt string `table:"last built"` - Outdated bool `table:"outdated"` - StartsAt string `table:"starts at"` - StopsAfter string `table:"stops after"` + // For JSON format: + codersdk.Workspace `table:"-"` + + // For table format: + WorkspaceName string `json:"-" table:"workspace,default_sort"` + Template string `json:"-" table:"template"` + Status string `json:"-" table:"status"` + LastBuilt string `json:"-" table:"last built"` + Outdated bool `json:"-" table:"outdated"` + StartsAt string `json:"-" table:"starts at"` + StopsAfter string `json:"-" table:"stops after"` } func workspaceListRowFromWorkspace(now time.Time, usersByID map[uuid.UUID]codersdk.User, workspace codersdk.Workspace) workspaceListRow { @@ -47,24 +53,27 @@ func workspaceListRowFromWorkspace(now time.Time, usersByID map[uuid.UUID]coders user := usersByID[workspace.OwnerID] return workspaceListRow{ - Workspace: user.Username + "/" + workspace.Name, - Template: workspace.TemplateName, - Status: status, - LastBuilt: durationDisplay(lastBuilt), - Outdated: workspace.Outdated, - StartsAt: autostartDisplay, - StopsAfter: autostopDisplay, + Workspace: workspace, + WorkspaceName: user.Username + "/" + workspace.Name, + Template: workspace.TemplateName, + Status: status, + LastBuilt: durationDisplay(lastBuilt), + Outdated: workspace.Outdated, + StartsAt: autostartDisplay, + StopsAfter: autostopDisplay, } } func list() *cobra.Command { var ( all bool - columns []string defaultQuery = "owner:me" searchQuery string - me bool displayWorkspaces []workspaceListRow + formatter = cliui.NewOutputFormatter( + cliui.TableFormat([]workspaceListRow{}, nil), + cliui.JSONFormat(), + ) ) cmd := &cobra.Command{ Annotations: workspaceCommand, @@ -85,14 +94,6 @@ func list() *cobra.Command { filter.FilterQuery = "" } - if me { - myUser, err := client.User(cmd.Context(), codersdk.Me) - if err != nil { - return err - } - filter.Owner = myUser.Username - } - res, err := client.Workspaces(cmd.Context(), filter) if err != nil { return err @@ -121,7 +122,7 @@ func list() *cobra.Command { displayWorkspaces[i] = workspaceListRowFromWorkspace(now, usersByID, workspace) } - out, err := cliui.DisplayTable(displayWorkspaces, "workspace", columns) + out, err := formatter.Format(cmd.Context(), displayWorkspaces) if err != nil { return err } @@ -131,16 +132,10 @@ func list() *cobra.Command { }, } - availColumns, err := cliui.TableHeaders(displayWorkspaces) - if err != nil { - panic(err) - } - columnString := strings.Join(availColumns[:], ", ") - cmd.Flags().BoolVarP(&all, "all", "a", false, "Specifies whether all workspaces will be listed or not.") - cmd.Flags().StringArrayVarP(&columns, "column", "c", nil, - fmt.Sprintf("Specify a column to filter in the table. Available columns are: %v", columnString)) cmd.Flags().StringVar(&searchQuery, "search", defaultQuery, "Search for a workspace with a query.") + + formatter.AttachFlags(cmd) return cmd } diff --git a/cli/list_test.go b/cli/list_test.go index e3309e05763f2..19b265724b817 100644 --- a/cli/list_test.go +++ b/cli/list_test.go @@ -1,13 +1,17 @@ package cli_test import ( + "bytes" "context" + "encoding/json" "testing" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" "github.com/coder/coder/cli/clitest" "github.com/coder/coder/coderd/coderdtest" + "github.com/coder/coder/codersdk" "github.com/coder/coder/pty/ptytest" "github.com/coder/coder/testutil" ) @@ -42,4 +46,30 @@ func TestList(t *testing.T) { cancelFunc() <-done }) + + t.Run("JSON", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) + user := coderdtest.CreateFirstUser(t, client) + version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) + coderdtest.AwaitTemplateVersionJob(t, client, version.ID) + template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) + workspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID) + coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID) + + cmd, root := clitest.New(t, "list", "--output=json") + clitest.SetupConfig(t, client, root) + + ctx, cancelFunc := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancelFunc() + + out := bytes.NewBuffer(nil) + cmd.SetOut(out) + err := cmd.ExecuteContext(ctx) + require.NoError(t, err) + + var templates []codersdk.Workspace + require.NoError(t, json.Unmarshal(out.Bytes(), &templates)) + require.Len(t, templates, 1) + }) } diff --git a/cli/parameterslist.go b/cli/parameterslist.go index 3978df26a850f..1249f2a642be7 100644 --- a/cli/parameterslist.go +++ b/cli/parameterslist.go @@ -12,9 +12,11 @@ import ( ) func parameterList() *cobra.Command { - var ( - columns []string + formatter := cliui.NewOutputFormatter( + cliui.TableFormat([]codersdk.Parameter{}, []string{"name", "scope", "destination scheme"}), + cliui.JSONFormat(), ) + cmd := &cobra.Command{ Use: "list", Aliases: []string{"ls"}, @@ -71,16 +73,16 @@ func parameterList() *cobra.Command { return xerrors.Errorf("fetch params: %w", err) } - out, err := cliui.DisplayTable(params, "name", columns) + out, err := formatter.Format(cmd.Context(), params) if err != nil { - return xerrors.Errorf("render table: %w", err) + return xerrors.Errorf("render output: %w", err) } _, err = fmt.Fprintln(cmd.OutOrStdout(), out) return err }, } - cmd.Flags().StringArrayVarP(&columns, "column", "c", []string{"name", "scope", "destination scheme"}, - "Specify a column to filter in the table.") + + formatter.AttachFlags(cmd) return cmd } diff --git a/cli/root_test.go b/cli/root_test.go index 58297f9403acd..525b305e1bf2c 100644 --- a/cli/root_test.go +++ b/cli/root_test.go @@ -2,12 +2,14 @@ package cli_test import ( "bytes" + "context" "flag" "fmt" "net/http" "net/http/httptest" "os" "path/filepath" + "regexp" "runtime" "strings" "testing" @@ -20,6 +22,8 @@ import ( "github.com/coder/coder/buildinfo" "github.com/coder/coder/cli" "github.com/coder/coder/cli/clitest" + "github.com/coder/coder/coderd/coderdtest" + "github.com/coder/coder/coderd/database/dbtestutil" "github.com/coder/coder/codersdk" "github.com/coder/coder/testutil" ) @@ -28,6 +32,8 @@ import ( // make update-golden-files var updateGoldenFiles = flag.Bool("update", false, "update .golden files") +var timestampRegex = regexp.MustCompile(`(?i)\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(.\d+)?Z`) + //nolint:tparallel,paralleltest // These test sets env vars. func TestCommandHelp(t *testing.T) { commonEnv := map[string]string{ @@ -35,6 +41,8 @@ func TestCommandHelp(t *testing.T) { "CODER_CONFIG_DIR": "~/.config/coderv2", } + rootClient, replacements := prepareTestData(t) + type testCase struct { name string cmd []string @@ -59,6 +67,14 @@ func TestCommandHelp(t *testing.T) { "CODER_AGENT_LOG_DIR": "/tmp", }, }, + { + name: "coder list --output json", + cmd: []string{"list", "--output", "json"}, + }, + { + name: "coder users list --output json", + cmd: []string{"users", "list", "--output", "json"}, + }, } root := cli.Root(cli.AGPL()) @@ -111,21 +127,33 @@ ExtractCommandPathsLoop: } err := os.Chdir(tmpwd) var buf bytes.Buffer - root, _ := clitest.New(t, tt.cmd...) - root.SetOut(&buf) + cmd, cfg := clitest.New(t, tt.cmd...) + clitest.SetupConfig(t, rootClient, cfg) + cmd.SetOut(&buf) assert.NoError(t, err) - err = root.ExecuteContext(ctx) + err = cmd.ExecuteContext(ctx) err2 := os.Chdir(wd) require.NoError(t, err) require.NoError(t, err2) got := buf.Bytes() - // Remove CRLF newlines (Windows). - got = bytes.ReplaceAll(got, []byte{'\r', '\n'}, []byte{'\n'}) - // The `coder templates create --help` command prints the path - // to the working directory (--directory flag default value). - got = bytes.ReplaceAll(got, []byte(fmt.Sprintf("%q", tmpwd)), []byte("\"[current directory]\"")) + replace := map[string][]byte{ + // Remove CRLF newlines (Windows). + string([]byte{'\r', '\n'}): []byte("\n"), + // The `coder templates create --help` command prints the path + // to the working directory (--directory flag default value). + fmt.Sprintf("%q", tmpwd): []byte("\"[current directory]\""), + } + for k, v := range replacements { + replace[k] = []byte(v) + } + for k, v := range replace { + got = bytes.ReplaceAll(got, []byte(k), v) + } + + // Replace any timestamps with a placeholder. + got = timestampRegex.ReplaceAll(got, []byte("[timestamp]")) gf := filepath.Join("testdata", strings.Replace(tt.name, " ", "_", -1)+".golden") if *updateGoldenFiles { @@ -156,6 +184,56 @@ func extractVisibleCommandPaths(cmdPath []string, cmds []*cobra.Command) [][]str return cmdPaths } +func prepareTestData(t *testing.T) (*codersdk.Client, map[string]string) { + t.Helper() + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + + db, pubsub := dbtestutil.NewDB(t) + rootClient := coderdtest.New(t, &coderdtest.Options{ + Database: db, + Pubsub: pubsub, + IncludeProvisionerDaemon: true, + }) + firstUser := coderdtest.CreateFirstUser(t, rootClient) + secondUser, err := rootClient.CreateUser(ctx, codersdk.CreateUserRequest{ + Email: "testuser2@coder.com", + Username: "testuser2", + Password: coderdtest.FirstUserParams.Password, + OrganizationID: firstUser.OrganizationID, + }) + require.NoError(t, err) + version := coderdtest.CreateTemplateVersion(t, rootClient, firstUser.OrganizationID, nil) + version = coderdtest.AwaitTemplateVersionJob(t, rootClient, version.ID) + template := coderdtest.CreateTemplate(t, rootClient, firstUser.OrganizationID, version.ID, func(req *codersdk.CreateTemplateRequest) { + req.Name = "test-template" + }) + workspace := coderdtest.CreateWorkspace(t, rootClient, firstUser.OrganizationID, template.ID, func(req *codersdk.CreateWorkspaceRequest) { + req.Name = "test-workspace" + }) + workspaceBuild := coderdtest.AwaitWorkspaceBuildJob(t, rootClient, workspace.LatestBuild.ID) + + replacements := map[string]string{ + firstUser.UserID.String(): "[first user ID]", + secondUser.ID.String(): "[second user ID]", + firstUser.OrganizationID.String(): "[first org ID]", + version.ID.String(): "[version ID]", + version.Name: "[version name]", + version.Job.ID.String(): "[version job ID]", + version.Job.FileID.String(): "[version file ID]", + version.Job.WorkerID.String(): "[version worker ID]", + template.ID.String(): "[template ID]", + workspace.ID.String(): "[workspace ID]", + workspaceBuild.ID.String(): "[workspace build ID]", + workspaceBuild.Job.ID.String(): "[workspace build job ID]", + workspaceBuild.Job.FileID.String(): "[workspace build file ID]", + workspaceBuild.Job.WorkerID.String(): "[workspace build worker ID]", + } + + return rootClient, replacements +} + func TestRoot(t *testing.T) { t.Parallel() t.Run("FormatCobraError", func(t *testing.T) { diff --git a/cli/ssh_test.go b/cli/ssh_test.go index 1e9ddf2e8f221..a943de616a34c 100644 --- a/cli/ssh_test.go +++ b/cli/ssh_test.go @@ -306,6 +306,9 @@ func TestSSH_ForwardGPG(t *testing.T) { // same process. t.Skip("Test not supported on windows") } + if testing.Short() { + t.SkipNow() + } // This key is for dean@coder.com. const randPublicKeyFingerprint = "7BDFBA0CC7F5A96537C806C427BC6335EB5117F1" diff --git a/cli/templatelist.go b/cli/templatelist.go index e528687a7459b..874f74e876c63 100644 --- a/cli/templatelist.go +++ b/cli/templatelist.go @@ -5,12 +5,16 @@ import ( "github.com/fatih/color" "github.com/spf13/cobra" + + "github.com/coder/coder/cli/cliui" ) func templateList() *cobra.Command { - var ( - columns []string + formatter := cliui.NewOutputFormatter( + cliui.TableFormat([]templateTableRow{}, []string{"name", "last updated", "used by"}), + cliui.JSONFormat(), ) + cmd := &cobra.Command{ Use: "list", Short: "List all the templates available for the organization", @@ -35,7 +39,8 @@ func templateList() *cobra.Command { return nil } - out, err := displayTemplates(columns, templates...) + rows := templatesToRows(templates...) + out, err := formatter.Format(cmd.Context(), rows) if err != nil { return err } @@ -44,7 +49,7 @@ func templateList() *cobra.Command { return err }, } - cmd.Flags().StringArrayVarP(&columns, "column", "c", []string{"name", "last_updated", "used_by"}, - "Specify a column to filter in the table.") + + formatter.AttachFlags(cmd) return cmd } diff --git a/cli/templatelist_test.go b/cli/templatelist_test.go index c113dc7e4f0b1..3063239b28df8 100644 --- a/cli/templatelist_test.go +++ b/cli/templatelist_test.go @@ -1,6 +1,9 @@ package cli_test import ( + "bytes" + "context" + "encoding/json" "sort" "testing" @@ -8,7 +11,9 @@ import ( "github.com/coder/coder/cli/clitest" "github.com/coder/coder/coderd/coderdtest" + "github.com/coder/coder/codersdk" "github.com/coder/coder/pty/ptytest" + "github.com/coder/coder/testutil" ) func TestTemplateList(t *testing.T) { @@ -32,12 +37,15 @@ func TestTemplateList(t *testing.T) { cmd.SetIn(pty.Input()) cmd.SetOut(pty.Output()) + ctx, cancelFunc := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancelFunc() + errC := make(chan error) go func() { - errC <- cmd.Execute() + errC <- cmd.ExecuteContext(ctx) }() - // expect that templates are listed alphebetically + // expect that templates are listed alphabetically var templatesList = []string{firstTemplate.Name, secondTemplate.Name} sort.Strings(templatesList) @@ -47,6 +55,33 @@ func TestTemplateList(t *testing.T) { pty.ExpectMatch(name) } }) + t.Run("ListTemplatesJSON", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) + user := coderdtest.CreateFirstUser(t, client) + firstVersion := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) + _ = coderdtest.AwaitTemplateVersionJob(t, client, firstVersion.ID) + _ = coderdtest.CreateTemplate(t, client, user.OrganizationID, firstVersion.ID) + + secondVersion := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) + _ = coderdtest.AwaitTemplateVersionJob(t, client, secondVersion.ID) + _ = coderdtest.CreateTemplate(t, client, user.OrganizationID, secondVersion.ID) + + cmd, root := clitest.New(t, "templates", "list", "--output=json") + clitest.SetupConfig(t, client, root) + + ctx, cancelFunc := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancelFunc() + + out := bytes.NewBuffer(nil) + cmd.SetOut(out) + err := cmd.ExecuteContext(ctx) + require.NoError(t, err) + + var templates []codersdk.Template + require.NoError(t, json.Unmarshal(out.Bytes(), &templates)) + require.Len(t, templates, 2) + }) t.Run("NoTemplates", func(t *testing.T) { t.Parallel() client := coderdtest.New(t, &coderdtest.Options{}) @@ -59,9 +94,12 @@ func TestTemplateList(t *testing.T) { cmd.SetIn(pty.Input()) cmd.SetErr(pty.Output()) + ctx, cancelFunc := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancelFunc() + errC := make(chan error) go func() { - errC <- cmd.Execute() + errC <- cmd.ExecuteContext(ctx) }() require.NoError(t, <-errC) diff --git a/cli/templates.go b/cli/templates.go index 7f8aa7e861792..58bd1bdde1742 100644 --- a/cli/templates.go +++ b/cli/templates.go @@ -50,23 +50,27 @@ func templates() *cobra.Command { } type templateTableRow struct { - Name string `table:"name"` - CreatedAt string `table:"created at"` - LastUpdated string `table:"last updated"` - OrganizationID uuid.UUID `table:"organization id"` - Provisioner codersdk.ProvisionerType `table:"provisioner"` - ActiveVersionID uuid.UUID `table:"active version id"` - UsedBy string `table:"used by"` - DefaultTTL time.Duration `table:"default ttl"` + // Used by json format: + Template codersdk.Template + + // Used by table format: + Name string `json:"-" table:"name,default_sort"` + CreatedAt string `json:"-" table:"created at"` + LastUpdated string `json:"-" table:"last updated"` + OrganizationID uuid.UUID `json:"-" table:"organization id"` + Provisioner codersdk.ProvisionerType `json:"-" table:"provisioner"` + ActiveVersionID uuid.UUID `json:"-" table:"active version id"` + UsedBy string `json:"-" table:"used by"` + DefaultTTL time.Duration `json:"-" table:"default ttl"` } -// displayTemplates will return a table displaying all templates passed in. -// filterColumns must be a subset of the template fields and will determine which -// columns to display -func displayTemplates(filterColumns []string, templates ...codersdk.Template) (string, error) { +// templateToRows converts a list of templates to a list of templateTableRow for +// outputting. +func templatesToRows(templates ...codersdk.Template) []templateTableRow { rows := make([]templateTableRow, len(templates)) for i, template := range templates { rows[i] = templateTableRow{ + Template: template, Name: template.Name, CreatedAt: template.CreatedAt.Format("January 2, 2006"), LastUpdated: template.UpdatedAt.Format("January 2, 2006"), @@ -78,5 +82,5 @@ func displayTemplates(filterColumns []string, templates ...codersdk.Template) (s } } - return cliui.DisplayTable(rows, "name", filterColumns) + return rows } diff --git a/cli/templateversions.go b/cli/templateversions.go index 147a15753b930..c8c50de2f3054 100644 --- a/cli/templateversions.go +++ b/cli/templateversions.go @@ -36,7 +36,12 @@ func templateVersions() *cobra.Command { } func templateVersionsList() *cobra.Command { - return &cobra.Command{ + formatter := cliui.NewOutputFormatter( + cliui.TableFormat([]templateVersionRow{}, nil), + cliui.JSONFormat(), + ) + + cmd := &cobra.Command{ Use: "list