From 1a959c6e9a375747e2514dd8dd3baf3ff955ddb8 Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Mon, 10 Jul 2023 13:59:32 -0400 Subject: [PATCH 1/8] feat: add table format to 'coder license ls' --- codersdk/licenses.go | 8 ++++---- enterprise/cli/licenses.go | 16 +++++++++++++--- 2 files changed, 17 insertions(+), 7 deletions(-) diff --git a/codersdk/licenses.go b/codersdk/licenses.go index 388055fd86bad..65a269fbca449 100644 --- a/codersdk/licenses.go +++ b/codersdk/licenses.go @@ -16,14 +16,14 @@ type AddLicenseRequest struct { } type License struct { - ID int32 `json:"id"` - UUID uuid.UUID `json:"uuid" format:"uuid"` - UploadedAt time.Time `json:"uploaded_at" format:"date-time"` + ID int32 `json:"id" table:"id,default_sort"` + UUID uuid.UUID `json:"uuid" table:"uuid" format:"uuid"` + UploadedAt time.Time `json:"uploaded_at" table:"uploaded_at" format:"date-time"` // Claims are the JWT claims asserted by the license. Here we use // 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. diff --git a/enterprise/cli/licenses.go b/enterprise/cli/licenses.go index 1ed12669ae9b0..f433cb1a1f0db 100644 --- a/enterprise/cli/licenses.go +++ b/enterprise/cli/licenses.go @@ -136,6 +136,11 @@ func validJWT(s string) error { } func (r *RootCmd) licensesList() *clibase.Cmd { + formatter := cliui.NewOutputFormatter( + cliui.TableFormat([]codersdk.License{}, []string{"UUID", "Claims", "Uploaded At"}), + cliui.JSONFormat(), + ) + client := new(codersdk.Client) cmd := &clibase.Cmd{ Use: "list", @@ -163,11 +168,16 @@ func (r *RootCmd) licensesList() *clibase.Cmd { licenses[i].Claims = newClaims } - enc := json.NewEncoder(inv.Stdout) - enc.SetIndent("", " ") - return enc.Encode(licenses) + out, err := formatter.Format(inv.Context(), licenses) + if err != nil { + return err + } + + _, err = fmt.Fprintln(inv.Stdout, out) + return err }, } + formatter.AttachOptions(&cmd.Options) return cmd } From 6fa7d8db2975f86673f420fa289e1cd43b106abb Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Tue, 11 Jul 2023 09:18:12 -0400 Subject: [PATCH 2/8] feat: license expires_at to table view --- codersdk/licenses.go | 34 ++++++++++++++++ enterprise/cli/licenses.go | 79 +++++++++++++++++++++----------------- 2 files changed, 78 insertions(+), 35 deletions(-) diff --git a/codersdk/licenses.go b/codersdk/licenses.go index 65a269fbca449..7b53c7bebf481 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"` } @@ -26,6 +30,36 @@ type License struct { 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") + } + + // The claim could be a unix timestamp or a RFC3339 formatted string. + // 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 + } + + if str, ok := expClaim.(string); ok { + t, err := time.Parse(time.RFC3339, str) + if err != nil { + return time.Time{}, xerrors.Errorf("license_expires claim is not a valid RFC3339 timestamp: %w", err) + } + return t, nil + } + + return time.Time{}, xerrors.Errorf("license_expires claim has unexpected type %T", expClaim) +} + // Features provides the feature claims in license. func (l *License) Features() (map[FeatureName]int64, error) { strMap, ok := l.Claims["features"].(map[string]interface{}) diff --git a/enterprise/cli/licenses.go b/enterprise/cli/licenses.go index f433cb1a1f0db..a740fa834f08b 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,8 +137,50 @@ 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"` + // Claims is the formatted string for the license claims. + // Used for the table view. + Claims string `table:"claims"` + ExpiresAt time.Time `table:"expires_at" format:"date-time"` + } + formatter := cliui.NewOutputFormatter( - cliui.TableFormat([]codersdk.License{}, []string{"UUID", "Claims", "Uploaded At"}), + cliui.ChangeFormatterData( + cliui.TableFormat([]tableLicense{}, []string{"UUID", "Expires At", "Uploaded At", "Claims"}), + 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 claims string + features, err := lic.Features() + if err != nil { + claims = xerrors.Errorf("invalid license: %w", err).Error() + } else { + var strs []string + for k, v := range features { + strs = append(strs, fmt.Sprintf("%s=%v", k, v)) + } + claims = strings.Join(strs, ", ") + } + // If this errors a zero time is returned. + exp, _ := lic.ExpiresAt() + + out = append(out, tableLicense{ + ID: lic.ID, + UUID: lic.UUID, + UploadedAt: lic.UploadedAt, + Claims: claims, + ExpiresAt: exp, + }) + } + return out, nil + }), cliui.JSONFormat(), ) @@ -160,14 +203,6 @@ 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 @@ -206,29 +241,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 -} From a05b88eabb300bf07deccd7980c7c3e0e13e836e Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Tue, 11 Jul 2023 09:28:25 -0400 Subject: [PATCH 3/8] Add back human expiration to json --- codersdk/licenses.go | 16 ++++------------ enterprise/cli/licenses.go | 15 ++++++++++++++- 2 files changed, 18 insertions(+), 13 deletions(-) diff --git a/codersdk/licenses.go b/codersdk/licenses.go index 7b53c7bebf481..07784c78db798 100644 --- a/codersdk/licenses.go +++ b/codersdk/licenses.go @@ -20,9 +20,9 @@ type AddLicenseRequest struct { } type License struct { - ID int32 `json:"id" table:"id,default_sort"` - UUID uuid.UUID `json:"uuid" table:"uuid" format:"uuid"` - UploadedAt time.Time `json:"uploaded_at" table:"uploaded_at" format:"date-time"` + ID int32 `json:"id"` + UUID uuid.UUID `json:"uuid" format:"uuid"` + UploadedAt time.Time `json:"uploaded_at" format:"date-time"` // Claims are the JWT claims asserted by the license. Here we use // a generic string map to ensure that all data from the server is // parsed verbatim, not just the fields this version of Coder @@ -38,7 +38,7 @@ func (l *License) ExpiresAt() (time.Time, error) { return time.Time{}, xerrors.New("license_expires claim is missing") } - // The claim could be a unix timestamp or a RFC3339 formatted string. + // 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 { @@ -49,14 +49,6 @@ func (l *License) ExpiresAt() (time.Time, error) { return time.Unix(i64, 0), nil } - if str, ok := expClaim.(string); ok { - t, err := time.Parse(time.RFC3339, str) - if err != nil { - return time.Time{}, xerrors.Errorf("license_expires claim is not a valid RFC3339 timestamp: %w", err) - } - return t, nil - } - return time.Time{}, xerrors.Errorf("license_expires claim has unexpected type %T", expClaim) } diff --git a/enterprise/cli/licenses.go b/enterprise/cli/licenses.go index a740fa834f08b..f8e3f18572e35 100644 --- a/enterprise/cli/licenses.go +++ b/enterprise/cli/licenses.go @@ -181,7 +181,20 @@ func (r *RootCmd) licensesList() *clibase.Cmd { } return out, nil }), - cliui.JSONFormat(), + 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) From 0de5426fe09fa3fd5b57204576490fbd7e363eda Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Tue, 11 Jul 2023 09:34:34 -0400 Subject: [PATCH 4/8] Parse all_features --- codersdk/licenses.go | 13 +++++++++++-- enterprise/cli/licenses.go | 21 ++++++++++++--------- enterprise/coderd/licenses_test.go | 6 +++--- 3 files changed, 26 insertions(+), 14 deletions(-) diff --git a/codersdk/licenses.go b/codersdk/licenses.go index 07784c78db798..12170b8519988 100644 --- a/codersdk/licenses.go +++ b/codersdk/licenses.go @@ -52,8 +52,17 @@ func (l *License) ExpiresAt() (time.Time, error) { return time.Time{}, xerrors.Errorf("license_expires claim has unexpected type %T", expClaim) } -// Features provides the feature claims in license. -func (l *License) Features() (map[FeatureName]int64, error) { +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/enterprise/cli/licenses.go b/enterprise/cli/licenses.go index f8e3f18572e35..85f2155a1b928 100644 --- a/enterprise/cli/licenses.go +++ b/enterprise/cli/licenses.go @@ -141,15 +141,15 @@ func (r *RootCmd) licensesList() *clibase.Cmd { ID int32 `table:"id,default_sort"` UUID uuid.UUID `table:"uuid" format:"uuid"` UploadedAt time.Time `table:"uploaded_at" format:"date-time"` - // Claims is the formatted string for the license claims. + // Features is the formatted string for the license claims. // Used for the table view. - Claims string `table:"claims"` + Features string `table:"features"` ExpiresAt time.Time `table:"expires_at" format:"date-time"` } formatter := cliui.NewOutputFormatter( cliui.ChangeFormatterData( - cliui.TableFormat([]tableLicense{}, []string{"UUID", "Expires At", "Uploaded At", "Claims"}), + cliui.TableFormat([]tableLicense{}, []string{"UUID", "Expires At", "Uploaded At", "Features"}), func(data any) (any, error) { list, ok := data.([]codersdk.License) if !ok { @@ -157,25 +157,28 @@ func (r *RootCmd) licensesList() *clibase.Cmd { } out := make([]tableLicense, 0, len(list)) for _, lic := range list { - var claims string - features, err := lic.Features() + var formattedFeatures string + features, err := lic.FeaturesClaims() if err != nil { - claims = xerrors.Errorf("invalid license: %w", err).Error() + formattedFeatures = xerrors.Errorf("invalid license: %w", err).Error() } else { var strs []string + if lic.AllFeaturesClaim() { + strs = append(strs, "all") + } for k, v := range features { strs = append(strs, fmt.Sprintf("%s=%v", k, v)) } - claims = strings.Join(strs, ", ") + formattedFeatures = strings.Join(strs, ", ") } - // If this errors a zero time is returned. + // 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, - Claims: claims, + Features: formattedFeatures, ExpiresAt: exp, }) } 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, From d185ff86d2bf97aa0bb4a42a5d4f1a80f333586f Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Tue, 11 Jul 2023 09:36:34 -0400 Subject: [PATCH 5/8] Add trial to parsed fields --- codersdk/licenses.go | 7 +++++++ enterprise/cli/licenses.go | 2 ++ 2 files changed, 9 insertions(+) diff --git a/codersdk/licenses.go b/codersdk/licenses.go index 12170b8519988..56bf63a9e6a43 100644 --- a/codersdk/licenses.go +++ b/codersdk/licenses.go @@ -52,6 +52,13 @@ func (l *License) ExpiresAt() (time.Time, error) { return time.Time{}, xerrors.Errorf("license_expires claim has unexpected type %T", expClaim) } +func (l *License) Trail() 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 diff --git a/enterprise/cli/licenses.go b/enterprise/cli/licenses.go index 85f2155a1b928..baf8bf3bda4c4 100644 --- a/enterprise/cli/licenses.go +++ b/enterprise/cli/licenses.go @@ -145,6 +145,7 @@ func (r *RootCmd) licensesList() *clibase.Cmd { // 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( @@ -180,6 +181,7 @@ func (r *RootCmd) licensesList() *clibase.Cmd { UploadedAt: lic.UploadedAt, Features: formattedFeatures, ExpiresAt: exp, + Trial: lic.Trail(), }) } return out, nil From 74d99c2f70325fb577dbc03e8382765f63074fcf Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Tue, 11 Jul 2023 09:52:21 -0400 Subject: [PATCH 6/8] make gen --- docs/cli/licenses_list.md | 22 ++++++++++++++++++- .../coder_licenses_list_--help.golden | 10 ++++++++- 2 files changed, 30 insertions(+), 2 deletions(-) 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/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 +Options + -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. From 3385ee90c6435f169dd27946f929e8130e2f475c Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Tue, 11 Jul 2023 10:01:41 -0400 Subject: [PATCH 7/8] Fix unit tests --- enterprise/cli/licenses_test.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) 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 From a4e475c97d549ea17d965b42475b0bdf8e8e78e5 Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Wed, 12 Jul 2023 07:50:50 -0400 Subject: [PATCH 8/8] fix typo, exclude 0 claims --- codersdk/licenses.go | 2 +- enterprise/cli/licenses.go | 15 ++++++++++----- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/codersdk/licenses.go b/codersdk/licenses.go index 56bf63a9e6a43..d7634c72bf4ff 100644 --- a/codersdk/licenses.go +++ b/codersdk/licenses.go @@ -52,7 +52,7 @@ func (l *License) ExpiresAt() (time.Time, error) { return time.Time{}, xerrors.Errorf("license_expires claim has unexpected type %T", expClaim) } -func (l *License) Trail() bool { +func (l *License) Trial() bool { if trail, ok := l.Claims["trail"].(bool); ok { return trail } diff --git a/enterprise/cli/licenses.go b/enterprise/cli/licenses.go index baf8bf3bda4c4..4258081df3e24 100644 --- a/enterprise/cli/licenses.go +++ b/enterprise/cli/licenses.go @@ -165,10 +165,15 @@ func (r *RootCmd) licensesList() *clibase.Cmd { } else { var strs []string if lic.AllFeaturesClaim() { - strs = append(strs, "all") - } - for k, v := range features { - strs = append(strs, fmt.Sprintf("%s=%v", k, v)) + // 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, ", ") } @@ -181,7 +186,7 @@ func (r *RootCmd) licensesList() *clibase.Cmd { UploadedAt: lic.UploadedAt, Features: formattedFeatures, ExpiresAt: exp, - Trial: lic.Trail(), + Trial: lic.Trial(), }) } return out, nil