diff --git a/codersdk/licenses.go b/codersdk/licenses.go
index 388055fd86bad..d7634c72bf4ff 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"`
+}
+
+// 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
}
-// Features provides the feature claims in license.
-func (l *License) Features() (map[FeatureName]int64, error) {
+// 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 670eae648104f..88b524dcea336 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 1ed12669ae9b0..4258081df3e24 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 fc4fb2bdc04ab..3428c7437c7aa 100644
--- a/enterprise/cli/licenses_test.go
+++ b/enterprise/cli/licenses_test.go
@@ -143,7 +143,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)
@@ -159,9 +159,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)
@@ -177,7 +177,7 @@ func TestLicensesListReal(t *testing.T) {
coderdtest.CreateFirstUser(t, client)
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 b04256e1d1839..7ccab7ae23f8d 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 4c0595cc12fa8..26eb950d0afb1 100644
--- a/enterprise/coderd/licenses_test.go
+++ b/enterprise/coderd/licenses_test.go
@@ -33,7 +33,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])
})
@@ -105,7 +105,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,
@@ -117,7 +117,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,
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: