Skip to content

feat(cli): include license status in support bundle #18472

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 3 commits into from
Jun 24, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
87 changes: 87 additions & 0 deletions cli/cliutil/license.go
Original file line number Diff line number Diff line change
@@ -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
}),
)
}
20 changes: 20 additions & 0 deletions cli/support.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package cli
import (
"archive/zip"
"bytes"
"context"
"encoding/base64"
"encoding/json"
"fmt"
Expand All @@ -13,6 +14,8 @@ import (
"text/tabwriter"
"time"

"github.com/coder/coder/v2/cli/cliutil"

"github.com/google/uuid"
"golang.org/x/xerrors"

Expand Down Expand Up @@ -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") +
Expand Down Expand Up @@ -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),
Expand All @@ -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 {
Expand Down Expand Up @@ -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)
}
3 changes: 3 additions & 0 deletions cli/support_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
74 changes: 2 additions & 72 deletions enterprise/cli/licenses.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand Down Expand Up @@ -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",
Expand Down
16 changes: 16 additions & 0 deletions support/support.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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))
}
Expand Down
1 change: 1 addition & 0 deletions support/support_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
Loading