coder features list CLI command (#3533)

* AGPL Entitlements API

Signed-off-by: Spike Curtis <spike@coder.com>

* Generate typesGenerated.ts

Signed-off-by: Spike Curtis <spike@coder.com>

* AllFeatures -> FeatureNames

Signed-off-by: Spike Curtis <spike@coder.com>

* Features CLI command

Signed-off-by: Spike Curtis <spike@coder.com>

* Validate columns

Signed-off-by: Spike Curtis <spike@coder.com>

* Tests for features list CLI command

Signed-off-by: Spike Curtis <spike@coder.com>

* Drop empty EntitlementsRequest

Signed-off-by: Spike Curtis <spike@coder.com>

* Fix dump.sql generation

Signed-off-by: Spike Curtis <spike@coder.com>

Signed-off-by: Spike Curtis <spike@coder.com>
This commit is contained in:
Spike Curtis
2022-08-17 11:26:16 -07:00
committed by GitHub
parent 5c898d0c83
commit acd0cd66f6
6 changed files with 215 additions and 1 deletions

View File

@ -301,3 +301,19 @@ func valueToTableMap(val reflect.Value) (map[string]any, error) {
return row, nil 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
}

112
cli/features.go Normal file
View File

@ -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)
}

66
cli/features_test.go Normal file
View File

@ -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)
})
}

View File

@ -135,6 +135,7 @@ func Root() *cobra.Command {
versionCmd(), versionCmd(),
wireguardPortForward(), wireguardPortForward(),
workspaceAgent(), workspaceAgent(),
features(),
) )
cmd.SetUsageTemplate(usageTemplate()) cmd.SetUsageTemplate(usageTemplate())

View File

@ -17,7 +17,7 @@ func entitlements(rw http.ResponseWriter, _ *http.Request) {
} }
httpapi.Write(rw, http.StatusOK, codersdk.Entitlements{ httpapi.Write(rw, http.StatusOK, codersdk.Entitlements{
Features: features, Features: features,
Warnings: nil, Warnings: []string{},
HasLicense: false, HasLicense: false,
}) })
} }

View File

@ -1,5 +1,11 @@
package codersdk package codersdk
import (
"context"
"encoding/json"
"net/http"
)
type Entitlement string type Entitlement string
const ( const (
@ -27,3 +33,16 @@ type Entitlements struct {
Warnings []string `json:"warnings"` Warnings []string `json:"warnings"`
HasLicense bool `json:"has_license"` 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)
}