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..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)
}
@@ -163,6 +162,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/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())
})
}
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")