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"
|
"golang.org/x/xerrors"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
LicenseExpiryClaim = "license_expires"
|
||||||
|
)
|
||||||
|
|
||||||
type AddLicenseRequest struct {
|
type AddLicenseRequest struct {
|
||||||
License string `json:"license" validate:"required"`
|
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
|
// a generic string map to ensure that all data from the server is
|
||||||
// parsed verbatim, not just the fields this version of Coder
|
// parsed verbatim, not just the fields this version of Coder
|
||||||
// understands.
|
// understands.
|
||||||
Claims map[string]interface{} `json:"claims"`
|
Claims map[string]interface{} `json:"claims" table:"claims"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Features provides the feature claims in license.
|
// ExpiresAt returns the expiration time of the license.
|
||||||
func (l *License) Features() (map[FeatureName]int64, error) {
|
// 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{})
|
strMap, ok := l.Claims["features"].(map[string]interface{})
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil, xerrors.New("features key is unexpected type")
|
return nil, xerrors.New("features key is unexpected type")
|
||||||
|
|
|
@ -11,5 +11,25 @@ Aliases:
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
```console
|
```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/clibase"
|
||||||
"github.com/coder/coder/cli/cliui"
|
"github.com/coder/coder/cli/cliui"
|
||||||
"github.com/coder/coder/codersdk"
|
"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_-]+$`)
|
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 {
|
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)
|
client := new(codersdk.Client)
|
||||||
cmd := &clibase.Cmd{
|
cmd := &clibase.Cmd{
|
||||||
Use: "list",
|
Use: "list",
|
||||||
|
@ -155,19 +226,16 @@ func (r *RootCmd) licensesList() *clibase.Cmd {
|
||||||
licenses = make([]codersdk.License, 0)
|
licenses = make([]codersdk.License, 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
for i, license := range licenses {
|
out, err := formatter.Format(inv.Context(), licenses)
|
||||||
newClaims, err := convertLicenseExpireTime(license.Claims)
|
if err != nil {
|
||||||
if err != nil {
|
return err
|
||||||
return err
|
|
||||||
}
|
|
||||||
licenses[i].Claims = newClaims
|
|
||||||
}
|
}
|
||||||
|
|
||||||
enc := json.NewEncoder(inv.Stdout)
|
_, err = fmt.Fprintln(inv.Stdout, out)
|
||||||
enc.SetIndent("", " ")
|
return err
|
||||||
return enc.Encode(licenses)
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
formatter.AttachOptions(&cmd.Options)
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -196,29 +264,3 @@ func (r *RootCmd) licenseDelete() *clibase.Cmd {
|
||||||
}
|
}
|
||||||
return 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)
|
expectedLicenseExpires := time.Date(2024, 4, 6, 16, 53, 35, 0, time.UTC)
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
inv := setupFakeLicenseServerTest(t, "licenses", "list")
|
inv := setupFakeLicenseServerTest(t, "licenses", "list", "-o", "json")
|
||||||
stdout := new(bytes.Buffer)
|
stdout := new(bytes.Buffer)
|
||||||
inv.Stdout = stdout
|
inv.Stdout = stdout
|
||||||
errC := make(chan error)
|
errC := make(chan error)
|
||||||
|
@ -157,9 +157,9 @@ func TestLicensesListFake(t *testing.T) {
|
||||||
assert.Equal(t, "claim1", licenses[0].Claims["h1"])
|
assert.Equal(t, "claim1", licenses[0].Claims["h1"])
|
||||||
assert.Equal(t, int32(5), licenses[1].ID)
|
assert.Equal(t, int32(5), licenses[1].ID)
|
||||||
assert.Equal(t, "claim2", licenses[1].Claims["h2"])
|
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)
|
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)
|
assert.NotEmpty(t, expiresClaim)
|
||||||
expiresTime, err := time.Parse(time.RFC3339, expiresString)
|
expiresTime, err := time.Parse(time.RFC3339, expiresString)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
@ -174,7 +174,7 @@ func TestLicensesListReal(t *testing.T) {
|
||||||
client, _ := coderdenttest.New(t, &coderdenttest.Options{DontAddLicense: true})
|
client, _ := coderdenttest.New(t, &coderdenttest.Options{DontAddLicense: true})
|
||||||
inv, conf := newCLI(
|
inv, conf := newCLI(
|
||||||
t,
|
t,
|
||||||
"licenses", "list",
|
"licenses", "list", "-o", "json",
|
||||||
)
|
)
|
||||||
stdout := new(bytes.Buffer)
|
stdout := new(bytes.Buffer)
|
||||||
inv.Stdout = stdout
|
inv.Stdout = stdout
|
||||||
|
|
|
@ -1,8 +1,16 @@
|
||||||
Usage: coder licenses list
|
Usage: coder licenses list [flags]
|
||||||
|
|
||||||
List licenses (including expired)
|
List licenses (including expired)
|
||||||
|
|
||||||
Aliases: ls
|
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.
|
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))
|
assert.GreaterOrEqual(t, respLic.ID, int32(0))
|
||||||
// just a couple spot checks for sanity
|
// just a couple spot checks for sanity
|
||||||
assert.Equal(t, "testing", respLic.Claims["account_id"])
|
assert.Equal(t, "testing", respLic.Claims["account_id"])
|
||||||
features, err := respLic.Features()
|
features, err := respLic.FeaturesClaims()
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.EqualValues(t, 1, features[codersdk.FeatureAuditLog])
|
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, int32(1), licenses[0].ID)
|
||||||
assert.Equal(t, "testing", licenses[0].Claims["account_id"])
|
assert.Equal(t, "testing", licenses[0].Claims["account_id"])
|
||||||
|
|
||||||
features, err := licenses[0].Features()
|
features, err := licenses[0].FeaturesClaims()
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, map[codersdk.FeatureName]int64{
|
assert.Equal(t, map[codersdk.FeatureName]int64{
|
||||||
codersdk.FeatureAuditLog: 1,
|
codersdk.FeatureAuditLog: 1,
|
||||||
|
@ -114,7 +114,7 @@ func TestGetLicense(t *testing.T) {
|
||||||
assert.Equal(t, "testing2", licenses[1].Claims["account_id"])
|
assert.Equal(t, "testing2", licenses[1].Claims["account_id"])
|
||||||
assert.Equal(t, true, licenses[1].Claims["trial"])
|
assert.Equal(t, true, licenses[1].Claims["trial"])
|
||||||
|
|
||||||
features, err = licenses[1].Features()
|
features, err = licenses[1].FeaturesClaims()
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, map[codersdk.FeatureName]int64{
|
assert.Equal(t, map[codersdk.FeatureName]int64{
|
||||||
codersdk.FeatureUserLimit: 200,
|
codersdk.FeatureUserLimit: 200,
|
||||||
|
|
Loading…
Reference in New Issue