From 5512cc0e33fc8465db399bd70b720d62b7249e4a Mon Sep 17 00:00:00 2001 From: Kacper Sawicki Date: Fri, 20 Jun 2025 14:16:26 +0000 Subject: [PATCH 1/3] Refactor license formatting into a reusable utility and add license status to the support bundle. Closes #18207 --- cli/cliutil/license.go | 97 ++++++++++++++++++++++++++++++++++++++ cli/support.go | 2 + cli/support_test.go | 3 ++ enterprise/cli/licenses.go | 74 +---------------------------- support/support.go | 41 +++++++++++++--- support/support_test.go | 1 + 6 files changed, 140 insertions(+), 78 deletions(-) create mode 100644 cli/cliutil/license.go diff --git a/cli/cliutil/license.go b/cli/cliutil/license.go new file mode 100644 index 0000000000000..eda19f1fe31f6 --- /dev/null +++ b/cli/cliutil/license.go @@ -0,0 +1,97 @@ +package cliutil + +import ( + "fmt" + "strings" + "time" + + "github.com/google/uuid" + "golang.org/x/xerrors" + + "github.com/coder/coder/v2/cli/cliui" + "github.com/coder/coder/v2/codersdk" +) + +// LicenseFormatterOpts are options for the license formatter. +type LicenseFormatterOpts struct { + Sanitize bool // If true, the UUID of the license will be redacted. +} + +// NewLicenseFormatter returns a new license formatter. +// The formatter will return a table and JSON output. +func NewLicenseFormatter(opts LicenseFormatterOpts) *cliui.OutputFormatter { + 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"` + } + + return cliui.NewOutputFormatter( + cliui.ChangeFormatterData( + cliui.TableFormat([]tableLicense{}, []string{"ID", "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() + + // If sanitize is true, we redact the UUID. + if opts.Sanitize { + lic.UUID = uuid.Nil + } + + 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 + }), + ) +} diff --git a/cli/support.go b/cli/support.go index fa7c58261bd41..5c93349f2cc12 100644 --- a/cli/support.go +++ b/cli/support.go @@ -48,6 +48,7 @@ var supportBundleBlurb = cliui.Bold("This will collect the following information - Agent details (with environment variable sanitized) - Agent network diagnostics - Agent logs + - License status (sanitized) ` + cliui.Bold("Note: ") + 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") + cliui.Bold("Please confirm that you will:\n") + @@ -315,6 +316,7 @@ func writeBundle(src *support.Bundle, dest *zip.Writer) error { "network/tailnet_debug.html": src.Network.TailnetDebug, "workspace/build_logs.txt": humanizeBuildLogs(src.Workspace.BuildLogs), "workspace/template_file.zip": string(templateVersionBytes), + "license-status.txt": src.LicenseStatus, } { f, err := dest.Create(k) if err != nil { diff --git a/cli/support_test.go b/cli/support_test.go index e1ad7fca7b0a4..46be69caa3bfd 100644 --- a/cli/support_test.go +++ b/cli/support_test.go @@ -386,6 +386,9 @@ func assertBundleContents(t *testing.T, path string, wantWorkspace bool, wantAge case "cli_logs.txt": bs := readBytesFromZip(t, f) require.NotEmpty(t, bs, "CLI logs should not be empty") + case "license-status.txt": + bs := readBytesFromZip(t, f) + require.NotEmpty(t, bs, "license status should not be empty") default: require.Failf(t, "unexpected file in bundle", f.Name) } diff --git a/enterprise/cli/licenses.go b/enterprise/cli/licenses.go index 9063af40fcf8f..5b1d8d8de9bc9 100644 --- a/enterprise/cli/licenses.go +++ b/enterprise/cli/licenses.go @@ -8,12 +8,11 @@ import ( "regexp" "strconv" "strings" - "time" - "github.com/google/uuid" "golang.org/x/xerrors" "github.com/coder/coder/v2/cli/cliui" + "github.com/coder/coder/v2/cli/cliutil" "github.com/coder/coder/v2/codersdk" "github.com/coder/serpent" ) @@ -137,76 +136,7 @@ func validJWT(s string) error { } func (r *RootCmd) licensesList() *serpent.Command { - 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{"ID", "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 - }), - ) - + formatter := cliutil.NewLicenseFormatter(cliutil.LicenseFormatterOpts{}) client := new(codersdk.Client) cmd := &serpent.Command{ Use: "list", diff --git a/support/support.go b/support/support.go index 30e9be934ead7..effc6ebd9a7a4 100644 --- a/support/support.go +++ b/support/support.go @@ -10,6 +10,8 @@ import ( "net/http/httptest" "strings" + "github.com/coder/coder/v2/cli/cliutil" + "github.com/google/uuid" "golang.org/x/sync/errgroup" "golang.org/x/xerrors" @@ -30,12 +32,13 @@ import ( // Even though we do attempt to sanitize data, it may still contain // sensitive information and should thus be treated as secret. type Bundle struct { - Deployment Deployment `json:"deployment"` - Network Network `json:"network"` - Workspace Workspace `json:"workspace"` - Agent Agent `json:"agent"` - Logs []string `json:"logs"` - CLILogs []byte `json:"cli_logs"` + Deployment Deployment `json:"deployment"` + Network Network `json:"network"` + Workspace Workspace `json:"workspace"` + Agent Agent `json:"agent"` + LicenseStatus string + Logs []string `json:"logs"` + CLILogs []byte `json:"cli_logs"` } type Deployment struct { @@ -351,6 +354,27 @@ func AgentInfo(ctx context.Context, client *codersdk.Client, log slog.Logger, ag return a } +func LicenseStatus(ctx context.Context, client *codersdk.Client, log slog.Logger) string { + licenses, err := client.Licenses(ctx) + if err != nil { + log.Warn(ctx, "fetch licenses", slog.Error(err)) + return "No licenses found" + } + // Ensure that we print "[]" instead of "null" when there are no licenses. + if licenses == nil { + licenses = make([]codersdk.License, 0) + } + + formatter := cliutil.NewLicenseFormatter(cliutil.LicenseFormatterOpts{ + Sanitize: true, + }) + out, err := formatter.Format(ctx, licenses) + if err != nil { + log.Error(ctx, "format licenses", slog.Error(err)) + } + return out +} + func connectedAgentInfo(ctx context.Context, client *codersdk.Client, log slog.Logger, agentID uuid.UUID, eg *errgroup.Group, a *Agent) (closer func()) { conn, err := workspacesdk.New(client). DialAgent(ctx, agentID, &workspacesdk.DialAgentOptions{ @@ -510,6 +534,11 @@ func Run(ctx context.Context, d *Deps) (*Bundle, error) { b.Agent = ai return nil }) + eg.Go(func() error { + ls := LicenseStatus(ctx, d.Client, d.Log) + b.LicenseStatus = ls + return nil + }) _ = eg.Wait() diff --git a/support/support_test.go b/support/support_test.go index 0c7d2af354044..0a6e6895ca03a 100644 --- a/support/support_test.go +++ b/support/support_test.go @@ -87,6 +87,7 @@ func TestRun(t *testing.T) { assertNotNilNotEmpty(t, bun.Agent.Prometheus, "agent prometheus metrics should be present") assertNotNilNotEmpty(t, bun.Agent.StartupLogs, "agent startup logs should be present") assertNotNilNotEmpty(t, bun.Logs, "bundle logs should be present") + assertNotNilNotEmpty(t, bun.LicenseStatus, "license status should be present") }) t.Run("OK_NoWorkspace", func(t *testing.T) { From 2951c2df8ea900d668ca67182ca5bcc2c6d1ddcd Mon Sep 17 00:00:00 2001 From: Kacper Sawicki Date: Mon, 23 Jun 2025 14:30:23 +0000 Subject: [PATCH 2/3] Move license retrieval to DeploymentInfo, refactor license formatting and catch errors --- cli/support.go | 22 +++++++++++++++- support/support.go | 57 ++++++++++++++++------------------------- support/support_test.go | 2 +- 3 files changed, 44 insertions(+), 37 deletions(-) diff --git a/cli/support.go b/cli/support.go index 5c93349f2cc12..f6bd2cf951db8 100644 --- a/cli/support.go +++ b/cli/support.go @@ -3,6 +3,7 @@ package cli import ( "archive/zip" "bytes" + "context" "encoding/base64" "encoding/json" "fmt" @@ -13,6 +14,8 @@ import ( "text/tabwriter" "time" + "github.com/coder/coder/v2/cli/cliutil" + "github.com/google/uuid" "golang.org/x/xerrors" @@ -303,6 +306,11 @@ func writeBundle(src *support.Bundle, dest *zip.Writer) error { return xerrors.Errorf("decode template zip from base64") } + licenseStatus, err := humanizeLicenses(src.Deployment.Licenses) + if err != nil { + return xerrors.Errorf("format license status: %w", err) + } + // The below we just write as we have them: for k, v := range map[string]string{ "agent/logs.txt": string(src.Agent.Logs), @@ -316,7 +324,7 @@ func writeBundle(src *support.Bundle, dest *zip.Writer) error { "network/tailnet_debug.html": src.Network.TailnetDebug, "workspace/build_logs.txt": humanizeBuildLogs(src.Workspace.BuildLogs), "workspace/template_file.zip": string(templateVersionBytes), - "license-status.txt": src.LicenseStatus, + "license-status.txt": licenseStatus, } { f, err := dest.Create(k) if err != nil { @@ -361,3 +369,15 @@ func humanizeBuildLogs(ls []codersdk.ProvisionerJobLog) string { _ = tw.Flush() return buf.String() } + +func humanizeLicenses(licenses []codersdk.License) (string, error) { + formatter := cliutil.NewLicenseFormatter(cliutil.LicenseFormatterOpts{ + Sanitize: true, + }) + + if len(licenses) == 0 { + return "No licenses found", nil + } + + return formatter.Format(context.Background(), licenses) +} diff --git a/support/support.go b/support/support.go index effc6ebd9a7a4..2fa41ce7eca8c 100644 --- a/support/support.go +++ b/support/support.go @@ -10,8 +10,6 @@ import ( "net/http/httptest" "strings" - "github.com/coder/coder/v2/cli/cliutil" - "github.com/google/uuid" "golang.org/x/sync/errgroup" "golang.org/x/xerrors" @@ -32,13 +30,12 @@ import ( // Even though we do attempt to sanitize data, it may still contain // sensitive information and should thus be treated as secret. type Bundle struct { - Deployment Deployment `json:"deployment"` - Network Network `json:"network"` - Workspace Workspace `json:"workspace"` - Agent Agent `json:"agent"` - LicenseStatus string - Logs []string `json:"logs"` - CLILogs []byte `json:"cli_logs"` + Deployment Deployment `json:"deployment"` + Network Network `json:"network"` + Workspace Workspace `json:"workspace"` + Agent Agent `json:"agent"` + Logs []string `json:"logs"` + CLILogs []byte `json:"cli_logs"` } type Deployment struct { @@ -46,6 +43,7 @@ type Deployment struct { Config *codersdk.DeploymentConfig `json:"config"` Experiments codersdk.Experiments `json:"experiments"` HealthReport *healthsdk.HealthcheckReport `json:"health_report"` + Licenses []codersdk.License `json:"licenses"` } type Network struct { @@ -141,6 +139,21 @@ func DeploymentInfo(ctx context.Context, client *codersdk.Client, log slog.Logge return nil }) + eg.Go(func() error { + licenses, err := client.Licenses(ctx) + if err != nil { + // Ignore 404 because AGPL doesn't have this endpoint + if cerr, ok := codersdk.AsError(err); ok && cerr.StatusCode() != http.StatusNotFound { + return xerrors.Errorf("fetch license status: %w", err) + } + } + if licenses == nil { + licenses = make([]codersdk.License, 0) + } + d.Licenses = licenses + return nil + }) + if err := eg.Wait(); err != nil { log.Error(ctx, "fetch deployment information", slog.Error(err)) } @@ -354,27 +367,6 @@ func AgentInfo(ctx context.Context, client *codersdk.Client, log slog.Logger, ag return a } -func LicenseStatus(ctx context.Context, client *codersdk.Client, log slog.Logger) string { - licenses, err := client.Licenses(ctx) - if err != nil { - log.Warn(ctx, "fetch licenses", slog.Error(err)) - return "No licenses found" - } - // Ensure that we print "[]" instead of "null" when there are no licenses. - if licenses == nil { - licenses = make([]codersdk.License, 0) - } - - formatter := cliutil.NewLicenseFormatter(cliutil.LicenseFormatterOpts{ - Sanitize: true, - }) - out, err := formatter.Format(ctx, licenses) - if err != nil { - log.Error(ctx, "format licenses", slog.Error(err)) - } - return out -} - func connectedAgentInfo(ctx context.Context, client *codersdk.Client, log slog.Logger, agentID uuid.UUID, eg *errgroup.Group, a *Agent) (closer func()) { conn, err := workspacesdk.New(client). DialAgent(ctx, agentID, &workspacesdk.DialAgentOptions{ @@ -534,11 +526,6 @@ func Run(ctx context.Context, d *Deps) (*Bundle, error) { b.Agent = ai return nil }) - eg.Go(func() error { - ls := LicenseStatus(ctx, d.Client, d.Log) - b.LicenseStatus = ls - return nil - }) _ = eg.Wait() diff --git a/support/support_test.go b/support/support_test.go index 0a6e6895ca03a..aeec0a44318f5 100644 --- a/support/support_test.go +++ b/support/support_test.go @@ -62,6 +62,7 @@ func TestRun(t *testing.T) { assertSanitizedDeploymentConfig(t, bun.Deployment.Config) assertNotNilNotEmpty(t, bun.Deployment.HealthReport, "deployment health report should be present") assertNotNilNotEmpty(t, bun.Deployment.Experiments, "deployment experiments should be present") + require.NotNil(t, bun.Deployment.Licenses, "license status should be present") assertNotNilNotEmpty(t, bun.Network.ConnectionInfo, "agent connection info should be present") assertNotNilNotEmpty(t, bun.Network.CoordinatorDebug, "network coordinator debug should be present") assertNotNilNotEmpty(t, bun.Network.Netcheck, "network netcheck should be present") @@ -87,7 +88,6 @@ func TestRun(t *testing.T) { assertNotNilNotEmpty(t, bun.Agent.Prometheus, "agent prometheus metrics should be present") assertNotNilNotEmpty(t, bun.Agent.StartupLogs, "agent startup logs should be present") assertNotNilNotEmpty(t, bun.Logs, "bundle logs should be present") - assertNotNilNotEmpty(t, bun.LicenseStatus, "license status should be present") }) t.Run("OK_NoWorkspace", func(t *testing.T) { From 6bfa3ffde690c0b9fbd10d53c13779229a8e81ca Mon Sep 17 00:00:00 2001 From: Kacper Sawicki Date: Tue, 24 Jun 2025 07:37:36 +0000 Subject: [PATCH 3/3] Remove sanitize option from license formatter --- cli/cliutil/license.go | 12 +----------- cli/support.go | 6 ++---- enterprise/cli/licenses.go | 2 +- 3 files changed, 4 insertions(+), 16 deletions(-) diff --git a/cli/cliutil/license.go b/cli/cliutil/license.go index eda19f1fe31f6..f4012ba665845 100644 --- a/cli/cliutil/license.go +++ b/cli/cliutil/license.go @@ -12,14 +12,9 @@ import ( "github.com/coder/coder/v2/codersdk" ) -// LicenseFormatterOpts are options for the license formatter. -type LicenseFormatterOpts struct { - Sanitize bool // If true, the UUID of the license will be redacted. -} - // NewLicenseFormatter returns a new license formatter. // The formatter will return a table and JSON output. -func NewLicenseFormatter(opts LicenseFormatterOpts) *cliui.OutputFormatter { +func NewLicenseFormatter() *cliui.OutputFormatter { type tableLicense struct { ID int32 `table:"id,default_sort"` UUID uuid.UUID `table:"uuid" format:"uuid"` @@ -63,11 +58,6 @@ func NewLicenseFormatter(opts LicenseFormatterOpts) *cliui.OutputFormatter { // If this returns an error, a zero time is returned. exp, _ := lic.ExpiresAt() - // If sanitize is true, we redact the UUID. - if opts.Sanitize { - lic.UUID = uuid.Nil - } - out = append(out, tableLicense{ ID: lic.ID, UUID: lic.UUID, diff --git a/cli/support.go b/cli/support.go index f6bd2cf951db8..70fadc3994580 100644 --- a/cli/support.go +++ b/cli/support.go @@ -51,7 +51,7 @@ var supportBundleBlurb = cliui.Bold("This will collect the following information - Agent details (with environment variable sanitized) - Agent network diagnostics - Agent logs - - License status (sanitized) + - License status ` + cliui.Bold("Note: ") + 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") + cliui.Bold("Please confirm that you will:\n") + @@ -371,9 +371,7 @@ func humanizeBuildLogs(ls []codersdk.ProvisionerJobLog) string { } func humanizeLicenses(licenses []codersdk.License) (string, error) { - formatter := cliutil.NewLicenseFormatter(cliutil.LicenseFormatterOpts{ - Sanitize: true, - }) + formatter := cliutil.NewLicenseFormatter() if len(licenses) == 0 { return "No licenses found", nil diff --git a/enterprise/cli/licenses.go b/enterprise/cli/licenses.go index 5b1d8d8de9bc9..1a730e1e82940 100644 --- a/enterprise/cli/licenses.go +++ b/enterprise/cli/licenses.go @@ -136,7 +136,7 @@ func validJWT(s string) error { } func (r *RootCmd) licensesList() *serpent.Command { - formatter := cliutil.NewLicenseFormatter(cliutil.LicenseFormatterOpts{}) + formatter := cliutil.NewLicenseFormatter() client := new(codersdk.Client) cmd := &serpent.Command{ Use: "list",