diff --git a/codersdk/licenses.go b/codersdk/licenses.go
index 388055fd86..d7634c72bf 100644
--- a/codersdk/licenses.go
+++ b/codersdk/licenses.go
@@ -11,6 +11,10 @@ import (
"golang.org/x/xerrors"
)
+const (
+ LicenseExpiryClaim = "license_expires"
+)
+
type AddLicenseRequest struct {
License string `json:"license" validate:"required"`
}
@@ -23,11 +27,49 @@ type License struct {
// a generic string map to ensure that all data from the server is
// parsed verbatim, not just the fields this version of Coder
// understands.
- Claims map[string]interface{} `json:"claims"`
+ Claims map[string]interface{} `json:"claims" table:"claims"`
}
-// Features provides the feature claims in license.
-func (l *License) Features() (map[FeatureName]int64, error) {
+// ExpiresAt returns the expiration time of the license.
+// If the claim is missing or has an unexpected type, an error is returned.
+func (l *License) ExpiresAt() (time.Time, error) {
+ expClaim, ok := l.Claims[LicenseExpiryClaim]
+ if !ok {
+ return time.Time{}, xerrors.New("license_expires claim is missing")
+ }
+
+ // This claim should be a unix timestamp.
+ // Everything is already an interface{}, so we need to do some type
+ // assertions to figure out what we're dealing with.
+ if unix, ok := expClaim.(json.Number); ok {
+ i64, err := unix.Int64()
+ if err != nil {
+ return time.Time{}, xerrors.Errorf("license_expires claim is not a valid unix timestamp: %w", err)
+ }
+ return time.Unix(i64, 0), nil
+ }
+
+ return time.Time{}, xerrors.Errorf("license_expires claim has unexpected type %T", expClaim)
+}
+
+func (l *License) Trial() bool {
+ if trail, ok := l.Claims["trail"].(bool); ok {
+ return trail
+ }
+ return false
+}
+
+func (l *License) AllFeaturesClaim() bool {
+ if all, ok := l.Claims["all_features"].(bool); ok {
+ return all
+ }
+ return false
+}
+
+// FeaturesClaims provides the feature claims in license.
+// This only returns the explicit claims. If checking for actual usage,
+// also check `AllFeaturesClaim`.
+func (l *License) FeaturesClaims() (map[FeatureName]int64, error) {
strMap, ok := l.Claims["features"].(map[string]interface{})
if !ok {
return nil, xerrors.New("features key is unexpected type")
diff --git a/docs/cli/licenses_list.md b/docs/cli/licenses_list.md
index 670eae6481..88b524dcea 100644
--- a/docs/cli/licenses_list.md
+++ b/docs/cli/licenses_list.md
@@ -11,5 +11,25 @@ Aliases:
## Usage
```console
-coder licenses list
+coder licenses list [flags]
```
+
+## Options
+
+### -c, --column
+
+| | |
+| ------- | ------------------------------------------------- |
+| Type | string-array
|
+| Default | UUID,Expires At,Uploaded At,Features
|
+
+Columns to display in table output. Available columns: id, uuid, uploaded at, features, expires at, trial.
+
+### -o, --output
+
+| | |
+| ------- | ------------------- |
+| Type | string
|
+| Default | table
|
+
+Output format. Available formats: table, json.
diff --git a/enterprise/cli/licenses.go b/enterprise/cli/licenses.go
index 1ed12669ae..4258081df3 100644
--- a/enterprise/cli/licenses.go
+++ b/enterprise/cli/licenses.go
@@ -15,6 +15,7 @@ import (
"github.com/coder/coder/cli/clibase"
"github.com/coder/coder/cli/cliui"
"github.com/coder/coder/codersdk"
+ "github.com/google/uuid"
)
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 {
}
func (r *RootCmd) licensesList() *clibase.Cmd {
+ type tableLicense struct {
+ ID int32 `table:"id,default_sort"`
+ UUID uuid.UUID `table:"uuid" format:"uuid"`
+ UploadedAt time.Time `table:"uploaded_at" format:"date-time"`
+ // Features is the formatted string for the license claims.
+ // Used for the table view.
+ Features string `table:"features"`
+ ExpiresAt time.Time `table:"expires_at" format:"date-time"`
+ Trial bool `table:"trial"`
+ }
+
+ formatter := cliui.NewOutputFormatter(
+ cliui.ChangeFormatterData(
+ cliui.TableFormat([]tableLicense{}, []string{"UUID", "Expires At", "Uploaded At", "Features"}),
+ func(data any) (any, error) {
+ list, ok := data.([]codersdk.License)
+ if !ok {
+ return nil, xerrors.Errorf("invalid data type %T", data)
+ }
+ out := make([]tableLicense, 0, len(list))
+ for _, lic := range list {
+ var formattedFeatures string
+ features, err := lic.FeaturesClaims()
+ if err != nil {
+ formattedFeatures = xerrors.Errorf("invalid license: %w", err).Error()
+ } else {
+ var strs []string
+ if lic.AllFeaturesClaim() {
+ // If all features are enabled, just include that
+ strs = append(strs, "all features")
+ } else {
+ for k, v := range features {
+ if v > 0 {
+ // Only include claims > 0
+ strs = append(strs, fmt.Sprintf("%s=%v", k, v))
+ }
+ }
+ }
+ formattedFeatures = strings.Join(strs, ", ")
+ }
+ // If this returns an error, a zero time is returned.
+ exp, _ := lic.ExpiresAt()
+
+ out = append(out, tableLicense{
+ ID: lic.ID,
+ UUID: lic.UUID,
+ UploadedAt: lic.UploadedAt,
+ Features: formattedFeatures,
+ ExpiresAt: exp,
+ Trial: lic.Trial(),
+ })
+ }
+ return out, nil
+ }),
+ cliui.ChangeFormatterData(cliui.JSONFormat(), func(data any) (any, error) {
+ list, ok := data.([]codersdk.License)
+ if !ok {
+ return nil, xerrors.Errorf("invalid data type %T", data)
+ }
+ for i := range list {
+ humanExp, err := list[i].ExpiresAt()
+ if err == nil {
+ list[i].Claims[codersdk.LicenseExpiryClaim+"_human"] = humanExp.Format(time.RFC3339)
+ }
+ }
+
+ return list, nil
+ }),
+ )
+
client := new(codersdk.Client)
cmd := &clibase.Cmd{
Use: "list",
@@ -155,19 +226,16 @@ func (r *RootCmd) licensesList() *clibase.Cmd {
licenses = make([]codersdk.License, 0)
}
- for i, license := range licenses {
- newClaims, err := convertLicenseExpireTime(license.Claims)
- if err != nil {
- return err
- }
- licenses[i].Claims = newClaims
+ out, err := formatter.Format(inv.Context(), licenses)
+ if err != nil {
+ return err
}
- enc := json.NewEncoder(inv.Stdout)
- enc.SetIndent("", " ")
- return enc.Encode(licenses)
+ _, err = fmt.Fprintln(inv.Stdout, out)
+ return err
},
}
+ formatter.AttachOptions(&cmd.Options)
return cmd
}
@@ -196,29 +264,3 @@ func (r *RootCmd) licenseDelete() *clibase.Cmd {
}
return cmd
}
-
-func convertLicenseExpireTime(licenseClaims map[string]interface{}) (map[string]interface{}, error) {
- if licenseClaims["license_expires"] != nil {
- licenseExpiresNumber, ok := licenseClaims["license_expires"].(json.Number)
- if !ok {
- return licenseClaims, xerrors.Errorf("could not convert license_expires to json.Number")
- }
-
- licenseExpires, err := licenseExpiresNumber.Int64()
- if err != nil {
- return licenseClaims, xerrors.Errorf("could not convert license_expires to int64: %w", err)
- }
-
- t := time.Unix(licenseExpires, 0)
- rfc3339Format := t.Format(time.RFC3339)
-
- claimsCopy := make(map[string]interface{}, len(licenseClaims))
- for k, v := range licenseClaims {
- claimsCopy[k] = v
- }
-
- claimsCopy["license_expires"] = rfc3339Format
- return claimsCopy, nil
- }
- return licenseClaims, nil
-}
diff --git a/enterprise/cli/licenses_test.go b/enterprise/cli/licenses_test.go
index a660a130e6..9272a103fa 100644
--- a/enterprise/cli/licenses_test.go
+++ b/enterprise/cli/licenses_test.go
@@ -141,7 +141,7 @@ func TestLicensesListFake(t *testing.T) {
expectedLicenseExpires := time.Date(2024, 4, 6, 16, 53, 35, 0, time.UTC)
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
- inv := setupFakeLicenseServerTest(t, "licenses", "list")
+ inv := setupFakeLicenseServerTest(t, "licenses", "list", "-o", "json")
stdout := new(bytes.Buffer)
inv.Stdout = stdout
errC := make(chan error)
@@ -157,9 +157,9 @@ func TestLicensesListFake(t *testing.T) {
assert.Equal(t, "claim1", licenses[0].Claims["h1"])
assert.Equal(t, int32(5), licenses[1].ID)
assert.Equal(t, "claim2", licenses[1].Claims["h2"])
- expiresClaim := licenses[0].Claims["license_expires"]
+ expiresClaim := licenses[0].Claims["license_expires_human"]
expiresString, ok := expiresClaim.(string)
- require.True(t, ok, "license_expires claim is not a string")
+ require.True(t, ok, "license_expires_human claim is not a string")
assert.NotEmpty(t, expiresClaim)
expiresTime, err := time.Parse(time.RFC3339, expiresString)
require.NoError(t, err)
@@ -174,7 +174,7 @@ func TestLicensesListReal(t *testing.T) {
client, _ := coderdenttest.New(t, &coderdenttest.Options{DontAddLicense: true})
inv, conf := newCLI(
t,
- "licenses", "list",
+ "licenses", "list", "-o", "json",
)
stdout := new(bytes.Buffer)
inv.Stdout = stdout
diff --git a/enterprise/cli/testdata/coder_licenses_list_--help.golden b/enterprise/cli/testdata/coder_licenses_list_--help.golden
index b04256e1d1..7ccab7ae23 100644
--- a/enterprise/cli/testdata/coder_licenses_list_--help.golden
+++ b/enterprise/cli/testdata/coder_licenses_list_--help.golden
@@ -1,8 +1,16 @@
-Usage: coder licenses list
+Usage: coder licenses list [flags]
List licenses (including expired)
Aliases: ls
+[1mOptions[0m
+ -c, --column string-array (default: UUID,Expires At,Uploaded At,Features)
+ Columns to display in table output. Available columns: id, uuid,
+ uploaded at, features, expires at, trial.
+
+ -o, --output string (default: table)
+ Output format. Available formats: table, json.
+
---
Run `coder --help` for a list of global options.
diff --git a/enterprise/coderd/licenses_test.go b/enterprise/coderd/licenses_test.go
index 273e64bba6..59b2e7f306 100644
--- a/enterprise/coderd/licenses_test.go
+++ b/enterprise/coderd/licenses_test.go
@@ -31,7 +31,7 @@ func TestPostLicense(t *testing.T) {
assert.GreaterOrEqual(t, respLic.ID, int32(0))
// just a couple spot checks for sanity
assert.Equal(t, "testing", respLic.Claims["account_id"])
- features, err := respLic.Features()
+ features, err := respLic.FeaturesClaims()
require.NoError(t, err)
assert.EqualValues(t, 1, features[codersdk.FeatureAuditLog])
})
@@ -102,7 +102,7 @@ func TestGetLicense(t *testing.T) {
assert.Equal(t, int32(1), licenses[0].ID)
assert.Equal(t, "testing", licenses[0].Claims["account_id"])
- features, err := licenses[0].Features()
+ features, err := licenses[0].FeaturesClaims()
require.NoError(t, err)
assert.Equal(t, map[codersdk.FeatureName]int64{
codersdk.FeatureAuditLog: 1,
@@ -114,7 +114,7 @@ func TestGetLicense(t *testing.T) {
assert.Equal(t, "testing2", licenses[1].Claims["account_id"])
assert.Equal(t, true, licenses[1].Claims["trial"])
- features, err = licenses[1].Features()
+ features, err = licenses[1].FeaturesClaims()
require.NoError(t, err)
assert.Equal(t, map[codersdk.FeatureName]int64{
codersdk.FeatureUserLimit: 200,