From 49807ab1ce68f04e463d59e13ba883da3536db34 Mon Sep 17 00:00:00 2001 From: Pavel Evdokimov Date: Tue, 2 Dec 2025 22:14:04 +0000 Subject: [PATCH 1/2] tsh request search, add support for --format flag --- lib/asciitable/example_test.go | 25 +++ lib/asciitable/struct.go | 93 ++++++++ lib/asciitable/struct_test.go | 178 ++++++++++++++++ lib/utils/jsontools.go | 8 + tool/tsh/common/access_request.go | 280 +++++++++++++++++-------- tool/tsh/common/access_request_test.go | 230 ++++++++++++++++++++ tool/tsh/common/tsh.go | 1 + 7 files changed, 722 insertions(+), 93 deletions(-) create mode 100644 lib/asciitable/struct.go create mode 100644 lib/asciitable/struct_test.go diff --git a/lib/asciitable/example_test.go b/lib/asciitable/example_test.go index d3d6f739e4d40..fc310062b84bc 100644 --- a/lib/asciitable/example_test.go +++ b/lib/asciitable/example_test.go @@ -34,3 +34,28 @@ func ExampleMakeTable() { // Write the table to stdout. fmt.Println(t.AsBuffer().String()) } + +func ExampleMakeColumnsAndRows() { + type dbResourceRow struct { + DatabaseName string `asciitable:"DB Name"` // This column will appear in the table under a custom name. + Skip string `asciitable:"-"` // This column will be skipped entirely. + ResourceID string // It will derive the name "Resource ID" + } + + rows := []dbResourceRow{ + {DatabaseName: "orders", Skip: "ignored", ResourceID: "db-1"}, + {DatabaseName: "users", Skip: "ignored", ResourceID: "db-2"}, + } + + // Build table columns + rows. + cols, data, err := MakeColumnsAndRows(rows, nil) + if err != nil { + panic(err) + } + + // Create asciitable table. + table := MakeTable(cols, data...) + + // Write the table to stdout. + fmt.Println(table.AsBuffer().String()) +} diff --git a/lib/asciitable/struct.go b/lib/asciitable/struct.go new file mode 100644 index 0000000000000..b53e700a60e27 --- /dev/null +++ b/lib/asciitable/struct.go @@ -0,0 +1,93 @@ +/* + * Teleport + * Copyright (C) 2025 Gravitational, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package asciitable + +import ( + "fmt" + "reflect" + "regexp" + "slices" + + "github.com/gravitational/trace" +) + +const asciitableTag = "asciitable" + +// Regular expression to convert from "DatabaseRoles" to "Database Roles" etc. +var headerSplitRe = regexp.MustCompile(`([a-z])([A-Z])`) + +// MakeColumnsAndRows converts a slice of structs into column headers and +// row data suitable for use with asciitable.MakeTable. +// T must be a struct type. If T is not a struct, the function returns an error. +// +// Column headers are determined by the `asciitable` struct tag. If the tag is +// empty, the header is derived from the field name (e.g., "DatabaseRoles" +// becomes "Database Roles"). +// +// includeColumns optionally restricts which columns are returned. Each value +// must match the final header name (tag value is used if present, otherwise the +// derived name). If includeColumns is empty or nil, all fields are included. +func MakeColumnsAndRows[T any](rows []T, includeColumns []string) ([]string, [][]string, error) { + t := reflect.TypeOf((*T)(nil)).Elem() + if t.Kind() != reflect.Struct { + return nil, nil, trace.Errorf("only slices of struct are supported: got slice of %s", t.Kind()) + } + + type fieldInfo struct { + index int + name string + } + + var fields []fieldInfo + var columns []string + + for i := 0; i < t.NumField(); i++ { + f := t.Field(i) + + header := f.Tag.Get(asciitableTag) + if header == "-" { + continue + } + if header == "" { + header = headerSplitRe.ReplaceAllString(f.Name, "${1} ${2}") + } + + if len(includeColumns) > 0 && !slices.Contains(includeColumns, header) { + continue + } + + fields = append(fields, fieldInfo{ + index: i, + name: header, + }) + columns = append(columns, header) + } + + outRows := make([][]string, 0, len(rows)) + for _, row := range rows { + v := reflect.ValueOf(row) + rowValues := make([]string, 0, len(fields)) + for _, fi := range fields { + rowValues = append(rowValues, fmt.Sprintf("%v", v.Field(fi.index))) + } + outRows = append(outRows, rowValues) + } + + return columns, outRows, nil +} diff --git a/lib/asciitable/struct_test.go b/lib/asciitable/struct_test.go new file mode 100644 index 0000000000000..11eb321e5d7bb --- /dev/null +++ b/lib/asciitable/struct_test.go @@ -0,0 +1,178 @@ +/* + * Teleport + * Copyright (C) 2025 Gravitational, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package asciitable + +import ( + "errors" + "testing" + + "github.com/gravitational/trace" + "github.com/stretchr/testify/require" +) + +func TestMakeColumnsAndRows(t *testing.T) { + type row struct { + Name string + ResourceID string + } + + rows := []row{ + {Name: "n1", ResourceID: "id1"}, + {Name: "n2", ResourceID: "id2"}, + } + + cols, data, err := MakeColumnsAndRows(rows, nil) + require.NoError(t, err) + + require.Equal(t, []string{"Name", "Resource ID"}, cols) + require.Equal(t, [][]string{ + {"n1", "id1"}, + {"n2", "id2"}, + }, data) +} + +func TestMakeColumnsAndRowsWithTagsAndSkip(t *testing.T) { + type row struct { + Name string `asciitable:"Custom Name"` + Skip string `asciitable:"-"` + ResourceID string `asciitable:"Resource ID"` + } + + rows := []row{ + {Name: "n1", Skip: "skip1", ResourceID: "id1"}, + {Name: "n2", Skip: "skip2", ResourceID: "id2"}, + } + + cols, data, err := MakeColumnsAndRows(rows, nil) + require.NoError(t, err) + + require.Equal(t, []string{"Custom Name", "Resource ID"}, cols) + require.Equal(t, [][]string{ + {"n1", "id1"}, + {"n2", "id2"}, + }, data) +} + +func TestMakeColumnsAndRowsIncludeColumns(t *testing.T) { + type row struct { + Name string + Hostname string + Labels string + ResourceID string + } + + rows := []row{ + {Name: "n1", Hostname: "h1", Labels: "a=1", ResourceID: "id1"}, + {Name: "n2", Hostname: "h2", Labels: "b=2", ResourceID: "id2"}, + } + + cols, data, err := MakeColumnsAndRows(rows, []string{"Name", "Labels"}) + require.NoError(t, err) + + require.Equal(t, []string{"Name", "Labels"}, cols) + require.Equal(t, [][]string{ + {"n1", "a=1"}, + {"n2", "b=2"}, + }, data) +} + +func TestMakeColumnsAndRowsIncludeColumnsWithTags(t *testing.T) { + type row struct { + Name string `asciitable:"Custom Name"` + ResourceID string `asciitable:"Resource ID"` + } + + rows := []row{ + {Name: "n1", ResourceID: "id1"}, + {Name: "n2", ResourceID: "id2"}, + } + + cols, data, err := MakeColumnsAndRows(rows, []string{"Custom Name"}) + require.NoError(t, err) + + require.Equal(t, []string{"Custom Name"}, cols) + require.Equal(t, [][]string{ + {"n1"}, + {"n2"}, + }, data) +} + +func TestMakeColumnsAndRowsCamelCaseLongName(t *testing.T) { + type row struct { + VeryLongFieldName string + } + + rows := []row{ + {VeryLongFieldName: "value1"}, + } + + cols, data, err := MakeColumnsAndRows(rows, nil) + require.NoError(t, err) + + require.Len(t, cols, 1) + require.Equal(t, "Very Long Field Name", cols[0]) + require.Equal(t, [][]string{{"value1"}}, data) +} + +func TestMakeColumnsAndRowsEmptySlice(t *testing.T) { + type row struct { + Name string + ResourceID string + } + + var rows []row + + cols, data, err := MakeColumnsAndRows(rows, nil) + require.NoError(t, err) + + require.Equal(t, []string{"Name", "Resource ID"}, cols) + require.Empty(t, data) +} + +func TestMakeColumnsAndRowsNonStructType(t *testing.T) { + rows := []int{1, 2, 3} + + cols, data, err := MakeColumnsAndRows(rows, nil) + require.Error(t, err) + require.Nil(t, cols) + require.Nil(t, data) + + var traceErr trace.Error + ok := errors.As(err, &traceErr) + require.True(t, ok) + + require.Contains(t, err.Error(), "only slices of struct are supported") +} + +func TestMakeColumnsAndRowsIncludeColumnsUnknown(t *testing.T) { + type row struct { + Name string + } + + rows := []row{ + {Name: "n1"}, + } + + cols, data, err := MakeColumnsAndRows(rows, []string{"Unknown"}) + require.NoError(t, err) + + require.Empty(t, cols) + require.Equal(t, [][]string{{}}, data) + require.Len(t, data, 1) +} diff --git a/lib/utils/jsontools.go b/lib/utils/jsontools.go index cf9c354c37648..fd2bbc4de6e42 100644 --- a/lib/utils/jsontools.go +++ b/lib/utils/jsontools.go @@ -163,6 +163,14 @@ func StreamJSONArray[T any](items stream.Stream[T], out io.Writer, indent bool) return trace.NewAggregate(items.Done(), stream.Flush()) } +// WriteYAMLArray marshals values as a YAML array. +func WriteYAMLArray[T any](w io.Writer, values []T) error { + if len(values) == 0 { + values = []T{} + } + return writeYAML(w, values) +} + const yamlDocDelimiter = "---" // WriteYAML detects whether value is a list diff --git a/tool/tsh/common/access_request.go b/tool/tsh/common/access_request.go index 32d4f606d4f48..8f76f220ecb15 100644 --- a/tool/tsh/common/access_request.go +++ b/tool/tsh/common/access_request.go @@ -423,6 +423,37 @@ func onRequestSearch(cf *CLIConf) error { } } +type requestableRoleRow struct { + Role string + Description string +} + +type resourceRow interface { + kubeResourceRow | + dbResourceRow | + genericResourceRow +} + +type kubeResourceRow struct { + Name string + Namespace string + Labels string + ResourceID string +} + +type dbResourceRow struct { + DatabaseName string + Labels string + ResourceID string +} + +type genericResourceRow struct { + Name string + Hostname string + Labels string + ResourceID string +} + func searchRequestableRoles(cf *CLIConf) error { tc, err := makeClient(cf) if err != nil { @@ -449,26 +480,51 @@ func searchRequestableRoles(cf *CLIConf) error { return trace.Wrap(err) } - if len(allRoles) == 0 { - fmt.Fprintln(cf.Stdout(), "No requestable roles found.") - return nil - } - - columns := []string{"Role", "Description"} - var rows [][]string + rows := make([]requestableRoleRow, 0, len(allRoles)) for _, r := range allRoles { - rows = append(rows, []string{r.Name, r.Description}) + rows = append(rows, requestableRoleRow{ + Role: r.Name, + Description: r.Description, + }) } - var table asciitable.Table - if cf.Verbose { - table = asciitable.MakeTable(columns, rows...) - } else { - table = asciitable.MakeTableWithTruncatedColumn(columns, rows, "Description") - } + return printRequestableRoles(cf, rows) +} - _, err = table.AsBuffer().WriteTo(cf.Stdout()) - return trace.Wrap(err) +func printRequestableRoles(cf *CLIConf, rows []requestableRoleRow) error { + format := strings.ToLower(cf.Format) + + switch format { + case teleport.Text, "": + if len(rows) == 0 { + fmt.Fprintln(cf.Stdout(), "No requestable roles found.") + return nil + } + + columns, rows, err := asciitable.MakeColumnsAndRows(rows, nil) + if err != nil { + return err + } + + var table asciitable.Table + if cf.Verbose { + table = asciitable.MakeTable(columns, rows...) + } else { + table = asciitable.MakeTableWithTruncatedColumn(columns, rows, "Description") + } + + if _, err := table.AsBuffer().WriteTo(cf.Stdout()); err != nil { + return trace.Wrap(err) + } + return nil + + case teleport.YAML: + return trace.Wrap(utils.WriteYAMLArray(cf.Stdout(), rows)) + case teleport.JSON: + return trace.Wrap(utils.WriteJSONArray(cf.Stdout(), rows)) + default: + return trace.BadParameter("unsupported format %q", cf.Format) + } } func searchRequestableResources(cf *CLIConf) error { @@ -490,9 +546,11 @@ func searchRequestableResources(cf *CLIConf) error { cf.kubeNamespace = "" } - var resources types.ResourcesWithLabels - var tableColumns []string - if cf.ResourceKind == types.KindKubernetesResource { + deduplicateResourceIDs := map[string]struct{}{} + var resourceIDs []string + + switch cf.ResourceKind { + case types.KindKubernetesResource: proxyGRPCClient, err := tc.NewKubernetesServiceClient(cf.Context, tc.SiteName) if err != nil { return trace.Wrap(err) @@ -512,13 +570,40 @@ func searchRequestableResources(cf *CLIConf) error { TeleportCluster: tc.SiteName, } - resources, err = client.GetKubernetesResourcesWithFilters(cf.Context, proxyGRPCClient, &req) + resources, err := client.GetKubernetesResourcesWithFilters(cf.Context, proxyGRPCClient, &req) if err != nil { return trace.Wrap(err) } - tableColumns = []string{"Name", "Namespace", "Labels", "Resource ID"} - } else { + var rows []kubeResourceRow + for _, resource := range resources { + r, ok := resource.(*types.KubernetesResourceV1) + if !ok { + continue + } + + resourceID := types.ResourceIDToString(types.ResourceID{ + ClusterName: tc.SiteName, + Kind: r.GetKind(), + Name: cf.KubernetesCluster, + SubResourceName: path.Join(r.Spec.Namespace, r.GetName()), + }) + if ignoreDuplicateResourceID(deduplicateResourceIDs, resourceID) { + continue + } + resourceIDs = append(resourceIDs, resourceID) + + rows = append(rows, kubeResourceRow{ + Name: common.FormatResourceName(r, cf.Verbose), + Namespace: r.Spec.Namespace, + Labels: common.FormatLabels(r.GetAllLabels(), cf.Verbose), + ResourceID: resourceID, + }) + } + + return printRequestableResources(cf, rows, resourceIDs) + + default: // For all other resources, we need to connect to the auth server. clusterClient, err := tc.ConnectToCluster(cf.Context) if err != nil { @@ -533,99 +618,108 @@ func searchRequestableResources(cf *CLIConf) error { UseSearchAsRoles: true, } - resources, err = accessrequest.GetResourcesByKind(cf.Context, clusterClient.AuthClient, req, cf.ResourceKind) + resources, err := accessrequest.GetResourcesByKind(cf.Context, clusterClient.AuthClient, req, cf.ResourceKind) if err != nil { return trace.Wrap(err) } switch cf.ResourceKind { case types.KindDatabase: - tableColumns = []string{"Database Name", "Labels", "Resource ID"} - default: - tableColumns = []string{"Name", "Hostname", "Labels", "Resource ID"} - } - } - - var rows [][]string - var resourceIDs []string - deduplicateResourceIDs := map[string]struct{}{} - for _, resource := range resources { - var row []string - switch r := resource.(type) { - case *types.KubernetesResourceV1: - resourceID := types.ResourceIDToString(types.ResourceID{ - ClusterName: tc.SiteName, - Kind: r.GetKind(), - Name: cf.KubernetesCluster, - SubResourceName: path.Join(r.Spec.Namespace, r.GetName()), - }) - if ignoreDuplicateResourceID(deduplicateResourceIDs, resourceID) { - continue - } - resourceIDs = append(resourceIDs, resourceID) + var rows []dbResourceRow + for _, resource := range resources { + r := resource + + resourceID := types.ResourceIDToString(types.ResourceID{ + ClusterName: tc.SiteName, + Kind: r.GetKind(), + Name: r.GetName(), + }) + if ignoreDuplicateResourceID(deduplicateResourceIDs, resourceID) { + continue + } + resourceIDs = append(resourceIDs, resourceID) - row = []string{ - common.FormatResourceName(r, cf.Verbose), - r.Spec.Namespace, - common.FormatLabels(r.GetAllLabels(), cf.Verbose), - resourceID, + rows = append(rows, dbResourceRow{ + DatabaseName: common.FormatResourceName(r, cf.Verbose), + Labels: common.FormatLabels(r.GetAllLabels(), cf.Verbose), + ResourceID: resourceID, + }) } + return printRequestableResources(cf, rows, resourceIDs) default: - resourceID := types.ResourceIDToString(types.ResourceID{ - ClusterName: tc.SiteName, - Kind: r.GetKind(), - Name: r.GetName(), - }) - if ignoreDuplicateResourceID(deduplicateResourceIDs, resourceID) { - continue - } - - resourceIDs = append(resourceIDs, resourceID) - hostName := "" - if r2, ok := r.(interface{ GetHostname() string }); ok { - hostName = r2.GetHostname() - } - - switch cf.ResourceKind { - case types.KindDatabase: - row = []string{ - common.FormatResourceName(r, cf.Verbose), - common.FormatLabels(r.GetAllLabels(), cf.Verbose), - resourceID, + var rows []genericResourceRow + for _, resource := range resources { + r := resource + + resourceID := types.ResourceIDToString(types.ResourceID{ + ClusterName: tc.SiteName, + Kind: r.GetKind(), + Name: r.GetName(), + }) + if ignoreDuplicateResourceID(deduplicateResourceIDs, resourceID) { + continue } - default: - row = []string{ - common.FormatResourceName(r, cf.Verbose), - hostName, - common.FormatLabels(r.GetAllLabels(), cf.Verbose), - resourceID, + resourceIDs = append(resourceIDs, resourceID) + + hostName := "" + if r2, ok := r.(interface{ GetHostname() string }); ok { + hostName = r2.GetHostname() } + + rows = append(rows, genericResourceRow{ + Name: common.FormatResourceName(r, cf.Verbose), + Hostname: hostName, + Labels: common.FormatLabels(r.GetAllLabels(), cf.Verbose), + ResourceID: resourceID, + }) } + + return printRequestableResources(cf, rows, resourceIDs) } - rows = append(rows, row) - } - var table asciitable.Table - if cf.Verbose { - table = asciitable.MakeTable(tableColumns, rows...) - } else { - table = asciitable.MakeTableWithTruncatedColumn(tableColumns, rows, "Labels") - } - if _, err := table.AsBuffer().WriteTo(cf.Stdout()); err != nil { - return trace.Wrap(err) } +} + +func printRequestableResources[T resourceRow](cf *CLIConf, rows []T, resourceIDs []string) error { + format := strings.ToLower(cf.Format) + + switch format { + case teleport.Text, "": + columns, tableRows, err := asciitable.MakeColumnsAndRows(rows, nil) + if err != nil { + return err + } + + var table asciitable.Table + if cf.Verbose { + table = asciitable.MakeTable(columns, tableRows...) + } else { + table = asciitable.MakeTableWithTruncatedColumn(columns, tableRows, "Labels") + } - if len(resourceIDs) > 0 { - resourcesStr := strings.Join(resourceIDs, " --resource ") - fmt.Fprintf(cf.Stdout(), ` + if _, err := table.AsBuffer().WriteTo(cf.Stdout()); err != nil { + return trace.Wrap(err) + } + + if len(resourceIDs) > 0 { + resourcesStr := strings.Join(resourceIDs, " --resource ") + fmt.Fprintf(cf.Stdout(), ` To request access to these resources, run > tsh request create --resource %s \ --reason `, resourcesStr) - } + } - return nil + return nil + + case teleport.YAML: + return trace.Wrap(utils.WriteYAMLArray(cf.Stdout(), rows)) + case teleport.JSON: + return trace.Wrap(utils.WriteJSONArray(cf.Stdout(), rows)) + default: + return trace.BadParameter("unsupported format %q", cf.Format) + } } // ignoreDuplicateResourceID returns true if the resource ID is a duplicate diff --git a/tool/tsh/common/access_request_test.go b/tool/tsh/common/access_request_test.go index 1016308123949..62438cd7f336c 100644 --- a/tool/tsh/common/access_request_test.go +++ b/tool/tsh/common/access_request_test.go @@ -413,3 +413,233 @@ func TestRequestSearchRequestableRoles(t *testing.T) { }) } } + +func TestPrintRequestableResources(t *testing.T) { + rows := []kubeResourceRow{ + { + Name: "pod-1", + Namespace: "default", + Labels: "env=prod", + ResourceID: "id1", + }, + { + Name: "pod-2", + Namespace: "dev", + Labels: "env=dev", + ResourceID: "id2", + }, + } + resourceIDs := []string{"id1", "id2"} + + t.Run("text", func(t *testing.T) { + var buf bytes.Buffer + cf := &CLIConf{ + OverrideStdout: &buf, + } + + err := printRequestableResources(cf, rows, resourceIDs) + require.NoError(t, err) + + // Build expected table using asciitable. + table := asciitable.MakeTable( + []string{"Name", "Namespace", "Labels", "Resource ID"}, + [][]string{ + {"pod-1", "default", "env=prod", "id1"}, + {"pod-2", "dev", "env=dev", "id2"}, + }..., + ) + expectedTable := table.AsBuffer().String() + out := buf.String() + + require.Contains(t, out, expectedTable) + require.Contains(t, out, "To request access to these resources, run") + }) + + t.Run("json", func(t *testing.T) { + var buf bytes.Buffer + cf := &CLIConf{ + OverrideStdout: &buf, + Format: "json", + } + + err := printRequestableResources(cf, rows, resourceIDs) + require.NoError(t, err) + + got := buf.String() + wantJSON := ` +[ +{ + "Name": "pod-1", + "Namespace": "default", + "Labels": "env=prod", + "ResourceID": "id1" +}, +{ + "Name": "pod-2", + "Namespace": "dev", + "Labels": "env=dev", + "ResourceID": "id2" +} +] +` + require.JSONEq(t, wantJSON, got) + }) + + t.Run("yaml", func(t *testing.T) { + var buf bytes.Buffer + cf := &CLIConf{ + OverrideStdout: &buf, + Format: "yaml", + } + + err := printRequestableResources(cf, rows, resourceIDs) + require.NoError(t, err) + + got := buf.String() + wantYAML := ` +- Name: pod-1 + Namespace: default + Labels: env=prod + ResourceID: id1 +- Name: pod-2 + Namespace: dev + Labels: env=dev + ResourceID: id2 +` + require.YAMLEq(t, wantYAML, got) + }) + + t.Run("empty rows text", func(t *testing.T) { + var buf bytes.Buffer + cf := &CLIConf{ + OverrideStdout: &buf, + Format: "", + Verbose: true, + } + + err := printRequestableResources(cf, []kubeResourceRow{}, nil) + require.NoError(t, err) + + out := buf.String() + table := asciitable.MakeTable( + []string{"Name", "Namespace", "Labels", "Resource ID"}, + ) + expectedTable := table.AsBuffer().String() + require.Equal(t, expectedTable, out) + }) + + t.Run("unsupported format", func(t *testing.T) { + var buf bytes.Buffer + cf := &CLIConf{ + OverrideStdout: &buf, + Format: "random", + } + + err := printRequestableResources(cf, rows, resourceIDs) + require.Error(t, err) + }) +} + +func TestPrintRequestableRoles(t *testing.T) { + rows := []requestableRoleRow{ + { + Role: "access", + Description: "base access role", + }, + { + Role: "db-admin", + Description: "database administrator role", + }, + } + + t.Run("text", func(t *testing.T) { + var buf bytes.Buffer + cf := &CLIConf{ + OverrideStdout: &buf, + } + + err := printRequestableRoles(cf, rows) + require.NoError(t, err) + + table := asciitable.MakeTable( + []string{"Role", "Description"}, + [][]string{ + {"access", "base access role"}, + {"db-admin", "database administrator role"}, + }..., + ) + expectedTable := table.AsBuffer().String() + out := buf.String() + + require.Equal(t, expectedTable, out) + }) + + t.Run("json", func(t *testing.T) { + var buf bytes.Buffer + cf := &CLIConf{ + OverrideStdout: &buf, + Format: "json", + } + + err := printRequestableRoles(cf, rows) + require.NoError(t, err) + + got := buf.String() + const wantJSON = ` +[ + { + "Role": "access", + "Description": "base access role" + }, + { + "Role": "db-admin", + "Description": "database administrator role" + } +] +` + require.JSONEq(t, wantJSON, got) + }) + + t.Run("yaml", func(t *testing.T) { + var buf bytes.Buffer + cf := &CLIConf{ + OverrideStdout: &buf, + Format: "yaml", + } + + err := printRequestableRoles(cf, rows) + require.NoError(t, err) + + got := buf.String() + const wantYAML = ` +- Role: access + Description: base access role +- Role: db-admin + Description: database administrator role +` + require.YAMLEq(t, wantYAML, got) + }) + + t.Run("empty roles text", func(t *testing.T) { + var buf bytes.Buffer + cf := &CLIConf{ + OverrideStdout: &buf, + } + + err := printRequestableRoles(cf, nil) + require.NoError(t, err) + + require.Equal(t, "No requestable roles found.\n", buf.String()) + }) + + t.Run("unsupported_format", func(t *testing.T) { + var buf bytes.Buffer + cf := &CLIConf{ + OverrideStdout: &buf, + Format: "random", + } + + err := printRequestableRoles(cf, rows) + require.Error(t, err) + }) +} diff --git a/tool/tsh/common/tsh.go b/tool/tsh/common/tsh.go index d7c56b2c2faa7..ef8b46276ea40 100644 --- a/tool/tsh/common/tsh.go +++ b/tool/tsh/common/tsh.go @@ -1373,6 +1373,7 @@ func Run(ctx context.Context, args []string, opts ...CliOption) error { reqSearch.Flag("roles", "List requestable roles instead of searching for resources. Mutually exclusive with --kind.").BoolVar(&cf.RequestableRoles) reqSearch.Flag("kube-kind", fmt.Sprintf("Kubernetes resource kind name (plural) to search for. Required with --kind=%q Ex: pods, deployments, namespaces, etc.", types.KindKubernetesResource)).StringVar(&cf.kubeResourceKind) reqSearch.Flag("kube-api-group", "Kubernetes API group to search for resources.").StringVar(&cf.kubeAPIGroup) + reqSearch.Flag("format", defaults.FormatFlagDescription(defaults.DefaultFormats...)).Short('f').Default(teleport.Text).EnumVar(&cf.Format, defaults.DefaultFormats...) reqSearch.PreAction(func(*kingpin.ParseContext) error { if cf.RequestableRoles && cf.ResourceKind != "" { return trace.BadParameter("only one of --kind and --roles may be specified") From 626a237bcdb319239e2c3a408e63a381ff5097a7 Mon Sep 17 00:00:00 2001 From: Pavel Evdokimov Date: Mon, 8 Dec 2025 21:04:34 +0000 Subject: [PATCH 2/2] fix missing newline when WriteJSONArray serializes empty arrays --- lib/utils/jsontools.go | 3 +-- tool/tctl/common/workload_identity_test.go | 4 ++-- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/lib/utils/jsontools.go b/lib/utils/jsontools.go index fd2bbc4de6e42..ebf7a3796ad87 100644 --- a/lib/utils/jsontools.go +++ b/lib/utils/jsontools.go @@ -117,8 +117,7 @@ func FastMarshalIndent(v any, prefix, indent string) ([]byte, error) { // WriteJSONArray marshals values as a JSON array. func WriteJSONArray[T any](w io.Writer, values []T) error { if len(values) == 0 { - _, err := w.Write([]byte("[]")) - return err + values = []T{} } return WriteJSON(w, values) } diff --git a/tool/tctl/common/workload_identity_test.go b/tool/tctl/common/workload_identity_test.go index 5a01154322638..417ac6a9aec8a 100644 --- a/tool/tctl/common/workload_identity_test.go +++ b/tool/tctl/common/workload_identity_test.go @@ -211,7 +211,7 @@ func TestWorkloadIdentityRevocation(t *testing.T) { }, ) require.NoError(t, err) - require.Equal(t, "[]", buf.String()) + require.Equal(t, "[]\n", buf.String()) }) t.Run("workload-identity revocations add", func(t *testing.T) { @@ -307,6 +307,6 @@ func TestWorkloadIdentityRevocation(t *testing.T) { }, ) require.NoError(t, err) - require.Equal(t, "[]", buf.String()) + require.Equal(t, "[]\n", buf.String()) }) }