mirror of https://github.com/coder/coder.git
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:
parent
2c2dd0eb83
commit
928091aa05
|
@ -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")
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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,
|
||||
|
|
Loading…
Reference in New Issue