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", pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy