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
This commit is contained in:
Steven Masley 2023-07-12 08:06:18 -04:00 committed by GitHub
parent 2c2dd0eb83
commit 928091aa05
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 159 additions and 47 deletions

View File

@ -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")

View File

@ -11,5 +11,25 @@ Aliases:
## Usage
```console
coder licenses list
coder licenses list [flags]
```
## Options
### -c, --column
| | |
| ------- | ------------------------------------------------- |
| Type | <code>string-array</code> |
| Default | <code>UUID,Expires At,Uploaded At,Features</code> |
Columns to display in table output. Available columns: id, uuid, uploaded at, features, expires at, trial.
### -o, --output
| | |
| ------- | ------------------- |
| Type | <code>string</code> |
| Default | <code>table</code> |
Output format. Available formats: table, json.

View File

@ -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
}

View File

@ -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

View File

@ -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.

View File

@ -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,