diff --git a/cli/cliui/table.go b/cli/cliui/table.go index 6a07e5d7c9496..a747aff625495 100644 --- a/cli/cliui/table.go +++ b/cli/cliui/table.go @@ -301,3 +301,19 @@ func valueToTableMap(val reflect.Value) (map[string]any, error) { return row, nil } + +func ValidateColumns(all, given []string) error { + for _, col := range given { + found := false + for _, c := range all { + if strings.EqualFold(strings.ReplaceAll(col, "_", " "), c) { + found = true + break + } + } + if !found { + return fmt.Errorf("unknown column: %s", col) + } + } + return nil +} diff --git a/cli/features.go b/cli/features.go new file mode 100644 index 0000000000000..307404d5c83c6 --- /dev/null +++ b/cli/features.go @@ -0,0 +1,112 @@ +package cli + +import ( + "encoding/json" + "fmt" + "strings" + + "github.com/coder/coder/cli/cliui" + "github.com/jedib0t/go-pretty/v6/table" + + "github.com/spf13/cobra" + "golang.org/x/xerrors" + + "github.com/coder/coder/codersdk" +) + +var featureColumns = []string{"Name", "Entitlement", "Enabled", "Limit", "Actual"} + +func features() *cobra.Command { + cmd := &cobra.Command{ + Short: "List features", + Use: "features", + Aliases: []string{"feature"}, + } + cmd.AddCommand( + featuresList(), + ) + return cmd +} + +func featuresList() *cobra.Command { + var ( + columns []string + outputFormat string + ) + + cmd := &cobra.Command{ + Use: "list", + Aliases: []string{"ls"}, + RunE: func(cmd *cobra.Command, args []string) error { + err := cliui.ValidateColumns(featureColumns, columns) + if err != nil { + return err + } + client, err := createClient(cmd) + if err != nil { + return err + } + entitlements, err := client.Entitlements(cmd.Context()) + if err != nil { + return err + } + + out := "" + switch outputFormat { + case "table", "": + out = displayFeatures(columns, entitlements.Features) + case "json": + outBytes, err := json.Marshal(entitlements) + if err != nil { + return xerrors.Errorf("marshal users to JSON: %w", err) + } + + out = string(outBytes) + default: + return xerrors.Errorf(`unknown output format %q, only "table" and "json" are supported`, outputFormat) + } + + _, err = fmt.Fprintln(cmd.OutOrStdout(), out) + return err + }, + } + + cmd.Flags().StringArrayVarP(&columns, "column", "c", featureColumns, + fmt.Sprintf("Specify a column to filter in the table. Available columns are: %s", + strings.Join(featureColumns, ", "))) + cmd.Flags().StringVarP(&outputFormat, "output", "o", "table", "Output format. Available formats are: table, json.") + return cmd +} + +// displayFeatures will return a table displaying all features passed in. +// filterColumns must be a subset of the feature fields and will determine which +// columns to display +func displayFeatures(filterColumns []string, features map[string]codersdk.Feature) string { + tableWriter := cliui.Table() + header := table.Row{} + for _, h := range featureColumns { + header = append(header, h) + } + tableWriter.AppendHeader(header) + tableWriter.SetColumnConfigs(cliui.FilterTableColumns(header, filterColumns)) + tableWriter.SortBy([]table.SortBy{{ + Name: "username", + }}) + for name, feat := range features { + tableWriter.AppendRow(table.Row{ + name, + feat.Entitlement, + feat.Enabled, + intOrNil(feat.Limit), + intOrNil(feat.Actual), + }) + } + return tableWriter.Render() +} + +func intOrNil(i *int64) string { + if i == nil { + return "" + } + return fmt.Sprintf("%d", *i) +} diff --git a/cli/features_test.go b/cli/features_test.go new file mode 100644 index 0000000000000..6c39fec81011a --- /dev/null +++ b/cli/features_test.go @@ -0,0 +1,66 @@ +package cli_test + +import ( + "bytes" + "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" +) + +func TestFeaturesList(t *testing.T) { + t.Parallel() + t.Run("Table", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, nil) + coderdtest.CreateFirstUser(t, client) + cmd, root := clitest.New(t, "features", "list") + clitest.SetupConfig(t, client, root) + pty := ptytest.New(t) + cmd.SetIn(pty.Input()) + cmd.SetOut(pty.Output()) + errC := make(chan error) + go func() { + errC <- cmd.Execute() + }() + require.NoError(t, <-errC) + pty.ExpectMatch("user_limit") + pty.ExpectMatch("not_entitled") + }) + t.Run("JSON", func(t *testing.T) { + t.Parallel() + + client := coderdtest.New(t, nil) + coderdtest.CreateFirstUser(t, client) + cmd, root := clitest.New(t, "features", "list", "-o", "json") + clitest.SetupConfig(t, client, root) + doneChan := make(chan struct{}) + + buf := bytes.NewBuffer(nil) + cmd.SetOut(buf) + go func() { + defer close(doneChan) + err := cmd.Execute() + assert.NoError(t, err) + }() + + <-doneChan + + var entitlements codersdk.Entitlements + err := json.Unmarshal(buf.Bytes(), &entitlements) + require.NoError(t, err, "unmarshal JSON output") + assert.Len(t, entitlements.Features, 2) + assert.Empty(t, entitlements.Warnings) + assert.Equal(t, codersdk.EntitlementNotEntitled, + entitlements.Features[codersdk.FeatureUserLimit].Entitlement) + assert.Equal(t, codersdk.EntitlementNotEntitled, + entitlements.Features[codersdk.FeatureAuditLog].Entitlement) + assert.False(t, entitlements.HasLicense) + }) +} diff --git a/cli/root.go b/cli/root.go index e63a308712f02..74a37ac4b6d8b 100644 --- a/cli/root.go +++ b/cli/root.go @@ -135,6 +135,7 @@ func Root() *cobra.Command { versionCmd(), wireguardPortForward(), workspaceAgent(), + features(), ) cmd.SetUsageTemplate(usageTemplate()) diff --git a/coderd/features.go b/coderd/features.go index e8fbc67612e4f..a6eaeca9c545b 100644 --- a/coderd/features.go +++ b/coderd/features.go @@ -17,7 +17,7 @@ func entitlements(rw http.ResponseWriter, _ *http.Request) { } httpapi.Write(rw, http.StatusOK, codersdk.Entitlements{ Features: features, - Warnings: nil, + Warnings: []string{}, HasLicense: false, }) } diff --git a/codersdk/features.go b/codersdk/features.go index 124a351dc69ed..6bdfd5ff53bd4 100644 --- a/codersdk/features.go +++ b/codersdk/features.go @@ -1,5 +1,11 @@ package codersdk +import ( + "context" + "encoding/json" + "net/http" +) + type Entitlement string const ( @@ -27,3 +33,16 @@ type Entitlements struct { Warnings []string `json:"warnings"` HasLicense bool `json:"has_license"` } + +func (c *Client) Entitlements(ctx context.Context) (Entitlements, error) { + res, err := c.Request(ctx, http.MethodGet, "/api/v2/entitlements", nil) + if err != nil { + return Entitlements{}, err + } + defer res.Body.Close() + if res.StatusCode != http.StatusOK { + return Entitlements{}, readBodyAsError(res) + } + var ent Entitlements + return ent, json.NewDecoder(res.Body).Decode(&ent) +}