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")
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: