From 928091aa050196b03e8e9b4c592260110e57f376 Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Wed, 12 Jul 2023 08:06:18 -0400 Subject: [PATCH] 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 --- codersdk/licenses.go | 48 +++++++- docs/cli/licenses_list.md | 22 +++- enterprise/cli/licenses.go | 112 ++++++++++++------ enterprise/cli/licenses_test.go | 8 +- .../coder_licenses_list_--help.golden | 10 +- enterprise/coderd/licenses_test.go | 6 +- 6 files changed, 159 insertions(+), 47 deletions(-) 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 +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. 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,