diff --git a/cli/cliutil/license.go b/cli/cliutil/license.go new file mode 100644 index 0000000000000..f4012ba665845 --- /dev/null +++ b/cli/cliutil/license.go @@ -0,0 +1,87 @@ +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" +) + +// NewLicenseFormatter returns a new license formatter. +// The formatter will return a table and JSON output. +func NewLicenseFormatter() *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() + + 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..70fadc3994580 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" @@ -48,6 +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 ` + 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") + @@ -302,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), @@ -315,6 +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": licenseStatus, } { f, err := dest.Create(k) if err != nil { @@ -359,3 +369,13 @@ func humanizeBuildLogs(ls []codersdk.ProvisionerJobLog) string { _ = tw.Flush() return buf.String() } + +func humanizeLicenses(licenses []codersdk.License) (string, error) { + formatter := cliutil.NewLicenseFormatter() + + if len(licenses) == 0 { + return "No licenses found", nil + } + + return formatter.Format(context.Background(), licenses) +} 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..1a730e1e82940 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() client := new(codersdk.Client) cmd := &serpent.Command{ Use: "list", diff --git a/support/support.go b/support/support.go index 30e9be934ead7..2fa41ce7eca8c 100644 --- a/support/support.go +++ b/support/support.go @@ -43,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 { @@ -138,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)) } diff --git a/support/support_test.go b/support/support_test.go index 0c7d2af354044..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")