Skip to content

Commit 5512cc0

Browse files
committed
Refactor license formatting into a reusable utility and add license status to the support bundle. Closes #18207
1 parent 4ceb549 commit 5512cc0

File tree

6 files changed

+140
-78
lines changed

6 files changed

+140
-78
lines changed

cli/cliutil/license.go

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
package cliutil
2+
3+
import (
4+
"fmt"
5+
"strings"
6+
"time"
7+
8+
"github.com/google/uuid"
9+
"golang.org/x/xerrors"
10+
11+
"github.com/coder/coder/v2/cli/cliui"
12+
"github.com/coder/coder/v2/codersdk"
13+
)
14+
15+
// LicenseFormatterOpts are options for the license formatter.
16+
type LicenseFormatterOpts struct {
17+
Sanitize bool // If true, the UUID of the license will be redacted.
18+
}
19+
20+
// NewLicenseFormatter returns a new license formatter.
21+
// The formatter will return a table and JSON output.
22+
func NewLicenseFormatter(opts LicenseFormatterOpts) *cliui.OutputFormatter {
23+
type tableLicense struct {
24+
ID int32 `table:"id,default_sort"`
25+
UUID uuid.UUID `table:"uuid" format:"uuid"`
26+
UploadedAt time.Time `table:"uploaded at" format:"date-time"`
27+
// Features is the formatted string for the license claims.
28+
// Used for the table view.
29+
Features string `table:"features"`
30+
ExpiresAt time.Time `table:"expires at" format:"date-time"`
31+
Trial bool `table:"trial"`
32+
}
33+
34+
return cliui.NewOutputFormatter(
35+
cliui.ChangeFormatterData(
36+
cliui.TableFormat([]tableLicense{}, []string{"ID", "UUID", "Expires At", "Uploaded At", "Features"}),
37+
func(data any) (any, error) {
38+
list, ok := data.([]codersdk.License)
39+
if !ok {
40+
return nil, xerrors.Errorf("invalid data type %T", data)
41+
}
42+
out := make([]tableLicense, 0, len(list))
43+
for _, lic := range list {
44+
var formattedFeatures string
45+
features, err := lic.FeaturesClaims()
46+
if err != nil {
47+
formattedFeatures = xerrors.Errorf("invalid license: %w", err).Error()
48+
} else {
49+
var strs []string
50+
if lic.AllFeaturesClaim() {
51+
// If all features are enabled, just include that
52+
strs = append(strs, "all features")
53+
} else {
54+
for k, v := range features {
55+
if v > 0 {
56+
// Only include claims > 0
57+
strs = append(strs, fmt.Sprintf("%s=%v", k, v))
58+
}
59+
}
60+
}
61+
formattedFeatures = strings.Join(strs, ", ")
62+
}
63+
// If this returns an error, a zero time is returned.
64+
exp, _ := lic.ExpiresAt()
65+
66+
// If sanitize is true, we redact the UUID.
67+
if opts.Sanitize {
68+
lic.UUID = uuid.Nil
69+
}
70+
71+
out = append(out, tableLicense{
72+
ID: lic.ID,
73+
UUID: lic.UUID,
74+
UploadedAt: lic.UploadedAt,
75+
Features: formattedFeatures,
76+
ExpiresAt: exp,
77+
Trial: lic.Trial(),
78+
})
79+
}
80+
return out, nil
81+
}),
82+
cliui.ChangeFormatterData(cliui.JSONFormat(), func(data any) (any, error) {
83+
list, ok := data.([]codersdk.License)
84+
if !ok {
85+
return nil, xerrors.Errorf("invalid data type %T", data)
86+
}
87+
for i := range list {
88+
humanExp, err := list[i].ExpiresAt()
89+
if err == nil {
90+
list[i].Claims[codersdk.LicenseExpiryClaim+"_human"] = humanExp.Format(time.RFC3339)
91+
}
92+
}
93+
94+
return list, nil
95+
}),
96+
)
97+
}

cli/support.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ var supportBundleBlurb = cliui.Bold("This will collect the following information
4848
- Agent details (with environment variable sanitized)
4949
- Agent network diagnostics
5050
- Agent logs
51+
- License status (sanitized)
5152
` + cliui.Bold("Note: ") +
5253
cliui.Wrap("While we try to sanitize sensitive data from support bundles, we cannot guarantee that they do not contain information that you or your organization may consider sensitive.\n") +
5354
cliui.Bold("Please confirm that you will:\n") +
@@ -315,6 +316,7 @@ func writeBundle(src *support.Bundle, dest *zip.Writer) error {
315316
"network/tailnet_debug.html": src.Network.TailnetDebug,
316317
"workspace/build_logs.txt": humanizeBuildLogs(src.Workspace.BuildLogs),
317318
"workspace/template_file.zip": string(templateVersionBytes),
319+
"license-status.txt": src.LicenseStatus,
318320
} {
319321
f, err := dest.Create(k)
320322
if err != nil {

cli/support_test.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -386,6 +386,9 @@ func assertBundleContents(t *testing.T, path string, wantWorkspace bool, wantAge
386386
case "cli_logs.txt":
387387
bs := readBytesFromZip(t, f)
388388
require.NotEmpty(t, bs, "CLI logs should not be empty")
389+
case "license-status.txt":
390+
bs := readBytesFromZip(t, f)
391+
require.NotEmpty(t, bs, "license status should not be empty")
389392
default:
390393
require.Failf(t, "unexpected file in bundle", f.Name)
391394
}

enterprise/cli/licenses.go

Lines changed: 2 additions & 72 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,11 @@ import (
88
"regexp"
99
"strconv"
1010
"strings"
11-
"time"
1211

13-
"github.com/google/uuid"
1412
"golang.org/x/xerrors"
1513

1614
"github.com/coder/coder/v2/cli/cliui"
15+
"github.com/coder/coder/v2/cli/cliutil"
1716
"github.com/coder/coder/v2/codersdk"
1817
"github.com/coder/serpent"
1918
)
@@ -137,76 +136,7 @@ func validJWT(s string) error {
137136
}
138137

139138
func (r *RootCmd) licensesList() *serpent.Command {
140-
type tableLicense struct {
141-
ID int32 `table:"id,default_sort"`
142-
UUID uuid.UUID `table:"uuid" format:"uuid"`
143-
UploadedAt time.Time `table:"uploaded at" format:"date-time"`
144-
// Features is the formatted string for the license claims.
145-
// Used for the table view.
146-
Features string `table:"features"`
147-
ExpiresAt time.Time `table:"expires at" format:"date-time"`
148-
Trial bool `table:"trial"`
149-
}
150-
151-
formatter := cliui.NewOutputFormatter(
152-
cliui.ChangeFormatterData(
153-
cliui.TableFormat([]tableLicense{}, []string{"ID", "UUID", "Expires At", "Uploaded At", "Features"}),
154-
func(data any) (any, error) {
155-
list, ok := data.([]codersdk.License)
156-
if !ok {
157-
return nil, xerrors.Errorf("invalid data type %T", data)
158-
}
159-
out := make([]tableLicense, 0, len(list))
160-
for _, lic := range list {
161-
var formattedFeatures string
162-
features, err := lic.FeaturesClaims()
163-
if err != nil {
164-
formattedFeatures = xerrors.Errorf("invalid license: %w", err).Error()
165-
} else {
166-
var strs []string
167-
if lic.AllFeaturesClaim() {
168-
// If all features are enabled, just include that
169-
strs = append(strs, "all features")
170-
} else {
171-
for k, v := range features {
172-
if v > 0 {
173-
// Only include claims > 0
174-
strs = append(strs, fmt.Sprintf("%s=%v", k, v))
175-
}
176-
}
177-
}
178-
formattedFeatures = strings.Join(strs, ", ")
179-
}
180-
// If this returns an error, a zero time is returned.
181-
exp, _ := lic.ExpiresAt()
182-
183-
out = append(out, tableLicense{
184-
ID: lic.ID,
185-
UUID: lic.UUID,
186-
UploadedAt: lic.UploadedAt,
187-
Features: formattedFeatures,
188-
ExpiresAt: exp,
189-
Trial: lic.Trial(),
190-
})
191-
}
192-
return out, nil
193-
}),
194-
cliui.ChangeFormatterData(cliui.JSONFormat(), func(data any) (any, error) {
195-
list, ok := data.([]codersdk.License)
196-
if !ok {
197-
return nil, xerrors.Errorf("invalid data type %T", data)
198-
}
199-
for i := range list {
200-
humanExp, err := list[i].ExpiresAt()
201-
if err == nil {
202-
list[i].Claims[codersdk.LicenseExpiryClaim+"_human"] = humanExp.Format(time.RFC3339)
203-
}
204-
}
205-
206-
return list, nil
207-
}),
208-
)
209-
139+
formatter := cliutil.NewLicenseFormatter(cliutil.LicenseFormatterOpts{})
210140
client := new(codersdk.Client)
211141
cmd := &serpent.Command{
212142
Use: "list",

support/support.go

Lines changed: 35 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ import (
1010
"net/http/httptest"
1111
"strings"
1212

13+
"github.com/coder/coder/v2/cli/cliutil"
14+
1315
"github.com/google/uuid"
1416
"golang.org/x/sync/errgroup"
1517
"golang.org/x/xerrors"
@@ -30,12 +32,13 @@ import (
3032
// Even though we do attempt to sanitize data, it may still contain
3133
// sensitive information and should thus be treated as secret.
3234
type Bundle struct {
33-
Deployment Deployment `json:"deployment"`
34-
Network Network `json:"network"`
35-
Workspace Workspace `json:"workspace"`
36-
Agent Agent `json:"agent"`
37-
Logs []string `json:"logs"`
38-
CLILogs []byte `json:"cli_logs"`
35+
Deployment Deployment `json:"deployment"`
36+
Network Network `json:"network"`
37+
Workspace Workspace `json:"workspace"`
38+
Agent Agent `json:"agent"`
39+
LicenseStatus string
40+
Logs []string `json:"logs"`
41+
CLILogs []byte `json:"cli_logs"`
3942
}
4043

4144
type Deployment struct {
@@ -351,6 +354,27 @@ func AgentInfo(ctx context.Context, client *codersdk.Client, log slog.Logger, ag
351354
return a
352355
}
353356

357+
func LicenseStatus(ctx context.Context, client *codersdk.Client, log slog.Logger) string {
358+
licenses, err := client.Licenses(ctx)
359+
if err != nil {
360+
log.Warn(ctx, "fetch licenses", slog.Error(err))
361+
return "No licenses found"
362+
}
363+
// Ensure that we print "[]" instead of "null" when there are no licenses.
364+
if licenses == nil {
365+
licenses = make([]codersdk.License, 0)
366+
}
367+
368+
formatter := cliutil.NewLicenseFormatter(cliutil.LicenseFormatterOpts{
369+
Sanitize: true,
370+
})
371+
out, err := formatter.Format(ctx, licenses)
372+
if err != nil {
373+
log.Error(ctx, "format licenses", slog.Error(err))
374+
}
375+
return out
376+
}
377+
354378
func connectedAgentInfo(ctx context.Context, client *codersdk.Client, log slog.Logger, agentID uuid.UUID, eg *errgroup.Group, a *Agent) (closer func()) {
355379
conn, err := workspacesdk.New(client).
356380
DialAgent(ctx, agentID, &workspacesdk.DialAgentOptions{
@@ -510,6 +534,11 @@ func Run(ctx context.Context, d *Deps) (*Bundle, error) {
510534
b.Agent = ai
511535
return nil
512536
})
537+
eg.Go(func() error {
538+
ls := LicenseStatus(ctx, d.Client, d.Log)
539+
b.LicenseStatus = ls
540+
return nil
541+
})
513542

514543
_ = eg.Wait()
515544

support/support_test.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,7 @@ func TestRun(t *testing.T) {
8787
assertNotNilNotEmpty(t, bun.Agent.Prometheus, "agent prometheus metrics should be present")
8888
assertNotNilNotEmpty(t, bun.Agent.StartupLogs, "agent startup logs should be present")
8989
assertNotNilNotEmpty(t, bun.Logs, "bundle logs should be present")
90+
assertNotNilNotEmpty(t, bun.LicenseStatus, "license status should be present")
9091
})
9192

9293
t.Run("OK_NoWorkspace", func(t *testing.T) {

0 commit comments

Comments
 (0)