Skip to content

Commit 928091a

Browse files
authored
feat!: add table format to 'coder license ls', 'license_expires' --> 'license_expires_human' (#8421)
* feat: add table format to 'coder license ls' * feat: license expires_at to table view * change: `license_expires` to `license_expires_human` and `license_expires` is unix timestamp
1 parent 2c2dd0e commit 928091a

File tree

6 files changed

+159
-47
lines changed

6 files changed

+159
-47
lines changed

codersdk/licenses.go

+45-3
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,10 @@ import (
1111
"golang.org/x/xerrors"
1212
)
1313

14+
const (
15+
LicenseExpiryClaim = "license_expires"
16+
)
17+
1418
type AddLicenseRequest struct {
1519
License string `json:"license" validate:"required"`
1620
}
@@ -23,11 +27,49 @@ type License struct {
2327
// a generic string map to ensure that all data from the server is
2428
// parsed verbatim, not just the fields this version of Coder
2529
// understands.
26-
Claims map[string]interface{} `json:"claims"`
30+
Claims map[string]interface{} `json:"claims" table:"claims"`
31+
}
32+
33+
// ExpiresAt returns the expiration time of the license.
34+
// If the claim is missing or has an unexpected type, an error is returned.
35+
func (l *License) ExpiresAt() (time.Time, error) {
36+
expClaim, ok := l.Claims[LicenseExpiryClaim]
37+
if !ok {
38+
return time.Time{}, xerrors.New("license_expires claim is missing")
39+
}
40+
41+
// This claim should be a unix timestamp.
42+
// Everything is already an interface{}, so we need to do some type
43+
// assertions to figure out what we're dealing with.
44+
if unix, ok := expClaim.(json.Number); ok {
45+
i64, err := unix.Int64()
46+
if err != nil {
47+
return time.Time{}, xerrors.Errorf("license_expires claim is not a valid unix timestamp: %w", err)
48+
}
49+
return time.Unix(i64, 0), nil
50+
}
51+
52+
return time.Time{}, xerrors.Errorf("license_expires claim has unexpected type %T", expClaim)
53+
}
54+
55+
func (l *License) Trial() bool {
56+
if trail, ok := l.Claims["trail"].(bool); ok {
57+
return trail
58+
}
59+
return false
60+
}
61+
62+
func (l *License) AllFeaturesClaim() bool {
63+
if all, ok := l.Claims["all_features"].(bool); ok {
64+
return all
65+
}
66+
return false
2767
}
2868

29-
// Features provides the feature claims in license.
30-
func (l *License) Features() (map[FeatureName]int64, error) {
69+
// FeaturesClaims provides the feature claims in license.
70+
// This only returns the explicit claims. If checking for actual usage,
71+
// also check `AllFeaturesClaim`.
72+
func (l *License) FeaturesClaims() (map[FeatureName]int64, error) {
3173
strMap, ok := l.Claims["features"].(map[string]interface{})
3274
if !ok {
3375
return nil, xerrors.New("features key is unexpected type")

docs/cli/licenses_list.md

+21-1
Original file line numberDiff line numberDiff line change
@@ -11,5 +11,25 @@ Aliases:
1111
## Usage
1212

1313
```console
14-
coder licenses list
14+
coder licenses list [flags]
1515
```
16+
17+
## Options
18+
19+
### -c, --column
20+
21+
| | |
22+
| ------- | ------------------------------------------------- |
23+
| Type | <code>string-array</code> |
24+
| Default | <code>UUID,Expires At,Uploaded At,Features</code> |
25+
26+
Columns to display in table output. Available columns: id, uuid, uploaded at, features, expires at, trial.
27+
28+
### -o, --output
29+
30+
| | |
31+
| ------- | ------------------- |
32+
| Type | <code>string</code> |
33+
| Default | <code>table</code> |
34+
35+
Output format. Available formats: table, json.

enterprise/cli/licenses.go

+77-35
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import (
1515
"github.com/coder/coder/cli/clibase"
1616
"github.com/coder/coder/cli/cliui"
1717
"github.com/coder/coder/codersdk"
18+
"github.com/google/uuid"
1819
)
1920

2021
var jwtRegexp = regexp.MustCompile(`^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$`)
@@ -136,6 +137,76 @@ func validJWT(s string) error {
136137
}
137138

138139
func (r *RootCmd) licensesList() *clibase.Cmd {
140+
type tableLicense struct {
141+
ID int32 `table:"id,default_sort"`
142+
UUID uuid.UUID `table:"uuid" format:"uuid"`
143+
UploadedAt time.Time `table:"uploaded_at" format:"date-time"`
144+
// Features is the formatted string for the license claims.
145+
// Used for the table view.
146+
Features string `table:"features"`
147+
ExpiresAt time.Time `table:"expires_at" format:"date-time"`
148+
Trial bool `table:"trial"`
149+
}
150+
151+
formatter := cliui.NewOutputFormatter(
152+
cliui.ChangeFormatterData(
153+
cliui.TableFormat([]tableLicense{}, []string{"UUID", "Expires At", "Uploaded At", "Features"}),
154+
func(data any) (any, error) {
155+
list, ok := data.([]codersdk.License)
156+
if !ok {
157+
return nil, xerrors.Errorf("invalid data type %T", data)
158+
}
159+
out := make([]tableLicense, 0, len(list))
160+
for _, lic := range list {
161+
var formattedFeatures string
162+
features, err := lic.FeaturesClaims()
163+
if err != nil {
164+
formattedFeatures = xerrors.Errorf("invalid license: %w", err).Error()
165+
} else {
166+
var strs []string
167+
if lic.AllFeaturesClaim() {
168+
// If all features are enabled, just include that
169+
strs = append(strs, "all features")
170+
} else {
171+
for k, v := range features {
172+
if v > 0 {
173+
// Only include claims > 0
174+
strs = append(strs, fmt.Sprintf("%s=%v", k, v))
175+
}
176+
}
177+
}
178+
formattedFeatures = strings.Join(strs, ", ")
179+
}
180+
// If this returns an error, a zero time is returned.
181+
exp, _ := lic.ExpiresAt()
182+
183+
out = append(out, tableLicense{
184+
ID: lic.ID,
185+
UUID: lic.UUID,
186+
UploadedAt: lic.UploadedAt,
187+
Features: formattedFeatures,
188+
ExpiresAt: exp,
189+
Trial: lic.Trial(),
190+
})
191+
}
192+
return out, nil
193+
}),
194+
cliui.ChangeFormatterData(cliui.JSONFormat(), func(data any) (any, error) {
195+
list, ok := data.([]codersdk.License)
196+
if !ok {
197+
return nil, xerrors.Errorf("invalid data type %T", data)
198+
}
199+
for i := range list {
200+
humanExp, err := list[i].ExpiresAt()
201+
if err == nil {
202+
list[i].Claims[codersdk.LicenseExpiryClaim+"_human"] = humanExp.Format(time.RFC3339)
203+
}
204+
}
205+
206+
return list, nil
207+
}),
208+
)
209+
139210
client := new(codersdk.Client)
140211
cmd := &clibase.Cmd{
141212
Use: "list",
@@ -155,19 +226,16 @@ func (r *RootCmd) licensesList() *clibase.Cmd {
155226
licenses = make([]codersdk.License, 0)
156227
}
157228

158-
for i, license := range licenses {
159-
newClaims, err := convertLicenseExpireTime(license.Claims)
160-
if err != nil {
161-
return err
162-
}
163-
licenses[i].Claims = newClaims
229+
out, err := formatter.Format(inv.Context(), licenses)
230+
if err != nil {
231+
return err
164232
}
165233

166-
enc := json.NewEncoder(inv.Stdout)
167-
enc.SetIndent("", " ")
168-
return enc.Encode(licenses)
234+
_, err = fmt.Fprintln(inv.Stdout, out)
235+
return err
169236
},
170237
}
238+
formatter.AttachOptions(&cmd.Options)
171239
return cmd
172240
}
173241

@@ -196,29 +264,3 @@ func (r *RootCmd) licenseDelete() *clibase.Cmd {
196264
}
197265
return cmd
198266
}
199-
200-
func convertLicenseExpireTime(licenseClaims map[string]interface{}) (map[string]interface{}, error) {
201-
if licenseClaims["license_expires"] != nil {
202-
licenseExpiresNumber, ok := licenseClaims["license_expires"].(json.Number)
203-
if !ok {
204-
return licenseClaims, xerrors.Errorf("could not convert license_expires to json.Number")
205-
}
206-
207-
licenseExpires, err := licenseExpiresNumber.Int64()
208-
if err != nil {
209-
return licenseClaims, xerrors.Errorf("could not convert license_expires to int64: %w", err)
210-
}
211-
212-
t := time.Unix(licenseExpires, 0)
213-
rfc3339Format := t.Format(time.RFC3339)
214-
215-
claimsCopy := make(map[string]interface{}, len(licenseClaims))
216-
for k, v := range licenseClaims {
217-
claimsCopy[k] = v
218-
}
219-
220-
claimsCopy["license_expires"] = rfc3339Format
221-
return claimsCopy, nil
222-
}
223-
return licenseClaims, nil
224-
}

enterprise/cli/licenses_test.go

+4-4
Original file line numberDiff line numberDiff line change
@@ -141,7 +141,7 @@ func TestLicensesListFake(t *testing.T) {
141141
expectedLicenseExpires := time.Date(2024, 4, 6, 16, 53, 35, 0, time.UTC)
142142
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
143143
defer cancel()
144-
inv := setupFakeLicenseServerTest(t, "licenses", "list")
144+
inv := setupFakeLicenseServerTest(t, "licenses", "list", "-o", "json")
145145
stdout := new(bytes.Buffer)
146146
inv.Stdout = stdout
147147
errC := make(chan error)
@@ -157,9 +157,9 @@ func TestLicensesListFake(t *testing.T) {
157157
assert.Equal(t, "claim1", licenses[0].Claims["h1"])
158158
assert.Equal(t, int32(5), licenses[1].ID)
159159
assert.Equal(t, "claim2", licenses[1].Claims["h2"])
160-
expiresClaim := licenses[0].Claims["license_expires"]
160+
expiresClaim := licenses[0].Claims["license_expires_human"]
161161
expiresString, ok := expiresClaim.(string)
162-
require.True(t, ok, "license_expires claim is not a string")
162+
require.True(t, ok, "license_expires_human claim is not a string")
163163
assert.NotEmpty(t, expiresClaim)
164164
expiresTime, err := time.Parse(time.RFC3339, expiresString)
165165
require.NoError(t, err)
@@ -174,7 +174,7 @@ func TestLicensesListReal(t *testing.T) {
174174
client, _ := coderdenttest.New(t, &coderdenttest.Options{DontAddLicense: true})
175175
inv, conf := newCLI(
176176
t,
177-
"licenses", "list",
177+
"licenses", "list", "-o", "json",
178178
)
179179
stdout := new(bytes.Buffer)
180180
inv.Stdout = stdout
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,16 @@
1-
Usage: coder licenses list
1+
Usage: coder licenses list [flags]
22

33
List licenses (including expired)
44

55
Aliases: ls
66

7+
Options
8+
-c, --column string-array (default: UUID,Expires At,Uploaded At,Features)
9+
Columns to display in table output. Available columns: id, uuid,
10+
uploaded at, features, expires at, trial.
11+
12+
-o, --output string (default: table)
13+
Output format. Available formats: table, json.
14+
715
---
816
Run `coder --help` for a list of global options.

enterprise/coderd/licenses_test.go

+3-3
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ func TestPostLicense(t *testing.T) {
3131
assert.GreaterOrEqual(t, respLic.ID, int32(0))
3232
// just a couple spot checks for sanity
3333
assert.Equal(t, "testing", respLic.Claims["account_id"])
34-
features, err := respLic.Features()
34+
features, err := respLic.FeaturesClaims()
3535
require.NoError(t, err)
3636
assert.EqualValues(t, 1, features[codersdk.FeatureAuditLog])
3737
})
@@ -102,7 +102,7 @@ func TestGetLicense(t *testing.T) {
102102
assert.Equal(t, int32(1), licenses[0].ID)
103103
assert.Equal(t, "testing", licenses[0].Claims["account_id"])
104104

105-
features, err := licenses[0].Features()
105+
features, err := licenses[0].FeaturesClaims()
106106
require.NoError(t, err)
107107
assert.Equal(t, map[codersdk.FeatureName]int64{
108108
codersdk.FeatureAuditLog: 1,
@@ -114,7 +114,7 @@ func TestGetLicense(t *testing.T) {
114114
assert.Equal(t, "testing2", licenses[1].Claims["account_id"])
115115
assert.Equal(t, true, licenses[1].Claims["trial"])
116116

117-
features, err = licenses[1].Features()
117+
features, err = licenses[1].FeaturesClaims()
118118
require.NoError(t, err)
119119
assert.Equal(t, map[codersdk.FeatureName]int64{
120120
codersdk.FeatureUserLimit: 200,

0 commit comments

Comments
 (0)