Skip to content

Commit acd0cd6

Browse files
authored
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>
1 parent 5c898d0 commit acd0cd6

File tree

6 files changed

+215
-1
lines changed

6 files changed

+215
-1
lines changed

cli/cliui/table.go

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -301,3 +301,19 @@ func valueToTableMap(val reflect.Value) (map[string]any, error) {
301301

302302
return row, nil
303303
}
304+
305+
func ValidateColumns(all, given []string) error {
306+
for _, col := range given {
307+
found := false
308+
for _, c := range all {
309+
if strings.EqualFold(strings.ReplaceAll(col, "_", " "), c) {
310+
found = true
311+
break
312+
}
313+
}
314+
if !found {
315+
return fmt.Errorf("unknown column: %s", col)
316+
}
317+
}
318+
return nil
319+
}

cli/features.go

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
package cli
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
"strings"
7+
8+
"github.com/coder/coder/cli/cliui"
9+
"github.com/jedib0t/go-pretty/v6/table"
10+
11+
"github.com/spf13/cobra"
12+
"golang.org/x/xerrors"
13+
14+
"github.com/coder/coder/codersdk"
15+
)
16+
17+
var featureColumns = []string{"Name", "Entitlement", "Enabled", "Limit", "Actual"}
18+
19+
func features() *cobra.Command {
20+
cmd := &cobra.Command{
21+
Short: "List features",
22+
Use: "features",
23+
Aliases: []string{"feature"},
24+
}
25+
cmd.AddCommand(
26+
featuresList(),
27+
)
28+
return cmd
29+
}
30+
31+
func featuresList() *cobra.Command {
32+
var (
33+
columns []string
34+
outputFormat string
35+
)
36+
37+
cmd := &cobra.Command{
38+
Use: "list",
39+
Aliases: []string{"ls"},
40+
RunE: func(cmd *cobra.Command, args []string) error {
41+
err := cliui.ValidateColumns(featureColumns, columns)
42+
if err != nil {
43+
return err
44+
}
45+
client, err := createClient(cmd)
46+
if err != nil {
47+
return err
48+
}
49+
entitlements, err := client.Entitlements(cmd.Context())
50+
if err != nil {
51+
return err
52+
}
53+
54+
out := ""
55+
switch outputFormat {
56+
case "table", "":
57+
out = displayFeatures(columns, entitlements.Features)
58+
case "json":
59+
outBytes, err := json.Marshal(entitlements)
60+
if err != nil {
61+
return xerrors.Errorf("marshal users to JSON: %w", err)
62+
}
63+
64+
out = string(outBytes)
65+
default:
66+
return xerrors.Errorf(`unknown output format %q, only "table" and "json" are supported`, outputFormat)
67+
}
68+
69+
_, err = fmt.Fprintln(cmd.OutOrStdout(), out)
70+
return err
71+
},
72+
}
73+
74+
cmd.Flags().StringArrayVarP(&columns, "column", "c", featureColumns,
75+
fmt.Sprintf("Specify a column to filter in the table. Available columns are: %s",
76+
strings.Join(featureColumns, ", ")))
77+
cmd.Flags().StringVarP(&outputFormat, "output", "o", "table", "Output format. Available formats are: table, json.")
78+
return cmd
79+
}
80+
81+
// displayFeatures will return a table displaying all features passed in.
82+
// filterColumns must be a subset of the feature fields and will determine which
83+
// columns to display
84+
func displayFeatures(filterColumns []string, features map[string]codersdk.Feature) string {
85+
tableWriter := cliui.Table()
86+
header := table.Row{}
87+
for _, h := range featureColumns {
88+
header = append(header, h)
89+
}
90+
tableWriter.AppendHeader(header)
91+
tableWriter.SetColumnConfigs(cliui.FilterTableColumns(header, filterColumns))
92+
tableWriter.SortBy([]table.SortBy{{
93+
Name: "username",
94+
}})
95+
for name, feat := range features {
96+
tableWriter.AppendRow(table.Row{
97+
name,
98+
feat.Entitlement,
99+
feat.Enabled,
100+
intOrNil(feat.Limit),
101+
intOrNil(feat.Actual),
102+
})
103+
}
104+
return tableWriter.Render()
105+
}
106+
107+
func intOrNil(i *int64) string {
108+
if i == nil {
109+
return ""
110+
}
111+
return fmt.Sprintf("%d", *i)
112+
}

cli/features_test.go

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
package cli_test
2+
3+
import (
4+
"bytes"
5+
"encoding/json"
6+
"testing"
7+
8+
"github.com/stretchr/testify/assert"
9+
"github.com/stretchr/testify/require"
10+
11+
"github.com/coder/coder/cli/clitest"
12+
"github.com/coder/coder/coderd/coderdtest"
13+
"github.com/coder/coder/codersdk"
14+
"github.com/coder/coder/pty/ptytest"
15+
)
16+
17+
func TestFeaturesList(t *testing.T) {
18+
t.Parallel()
19+
t.Run("Table", func(t *testing.T) {
20+
t.Parallel()
21+
client := coderdtest.New(t, nil)
22+
coderdtest.CreateFirstUser(t, client)
23+
cmd, root := clitest.New(t, "features", "list")
24+
clitest.SetupConfig(t, client, root)
25+
pty := ptytest.New(t)
26+
cmd.SetIn(pty.Input())
27+
cmd.SetOut(pty.Output())
28+
errC := make(chan error)
29+
go func() {
30+
errC <- cmd.Execute()
31+
}()
32+
require.NoError(t, <-errC)
33+
pty.ExpectMatch("user_limit")
34+
pty.ExpectMatch("not_entitled")
35+
})
36+
t.Run("JSON", func(t *testing.T) {
37+
t.Parallel()
38+
39+
client := coderdtest.New(t, nil)
40+
coderdtest.CreateFirstUser(t, client)
41+
cmd, root := clitest.New(t, "features", "list", "-o", "json")
42+
clitest.SetupConfig(t, client, root)
43+
doneChan := make(chan struct{})
44+
45+
buf := bytes.NewBuffer(nil)
46+
cmd.SetOut(buf)
47+
go func() {
48+
defer close(doneChan)
49+
err := cmd.Execute()
50+
assert.NoError(t, err)
51+
}()
52+
53+
<-doneChan
54+
55+
var entitlements codersdk.Entitlements
56+
err := json.Unmarshal(buf.Bytes(), &entitlements)
57+
require.NoError(t, err, "unmarshal JSON output")
58+
assert.Len(t, entitlements.Features, 2)
59+
assert.Empty(t, entitlements.Warnings)
60+
assert.Equal(t, codersdk.EntitlementNotEntitled,
61+
entitlements.Features[codersdk.FeatureUserLimit].Entitlement)
62+
assert.Equal(t, codersdk.EntitlementNotEntitled,
63+
entitlements.Features[codersdk.FeatureAuditLog].Entitlement)
64+
assert.False(t, entitlements.HasLicense)
65+
})
66+
}

cli/root.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,7 @@ func Root() *cobra.Command {
135135
versionCmd(),
136136
wireguardPortForward(),
137137
workspaceAgent(),
138+
features(),
138139
)
139140

140141
cmd.SetUsageTemplate(usageTemplate())

coderd/features.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ func entitlements(rw http.ResponseWriter, _ *http.Request) {
1717
}
1818
httpapi.Write(rw, http.StatusOK, codersdk.Entitlements{
1919
Features: features,
20-
Warnings: nil,
20+
Warnings: []string{},
2121
HasLicense: false,
2222
})
2323
}

codersdk/features.go

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
package codersdk
22

3+
import (
4+
"context"
5+
"encoding/json"
6+
"net/http"
7+
)
8+
39
type Entitlement string
410

511
const (
@@ -27,3 +33,16 @@ type Entitlements struct {
2733
Warnings []string `json:"warnings"`
2834
HasLicense bool `json:"has_license"`
2935
}
36+
37+
func (c *Client) Entitlements(ctx context.Context) (Entitlements, error) {
38+
res, err := c.Request(ctx, http.MethodGet, "/api/v2/entitlements", nil)
39+
if err != nil {
40+
return Entitlements{}, err
41+
}
42+
defer res.Body.Close()
43+
if res.StatusCode != http.StatusOK {
44+
return Entitlements{}, readBodyAsError(res)
45+
}
46+
var ent Entitlements
47+
return ent, json.NewDecoder(res.Body).Decode(&ent)
48+
}

0 commit comments

Comments
 (0)
pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy