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

Lines changed: 45 additions & 3 deletions
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

Lines changed: 21 additions & 1 deletion
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

Lines changed: 77 additions & 35 deletions
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

Lines changed: 4 additions & 4 deletions
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
Lines changed: 9 additions & 1 deletion
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

Lines changed: 3 additions & 3 deletions
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)
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