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

Closes #18207

This PR adds license status to support bundle to help with
troubleshooting license-related issues.

- `license-status.txt`, is added to the support bundle.
    - it contains the same output as the `coder license list` command.
- license output formatter logic has been extracted into a separate
function.
- this allows it to be reused both in the `coder license list` cmd and
in the support bundle generation.
This commit is contained in:
Kacper Sawicki
2025-06-24 11:16:31 +02:00
committed by GitHub
parent 2afd1a203e
commit 7c40f86a6a
6 changed files with 129 additions and 72 deletions

87
cli/cliutil/license.go Normal file
View File

@ -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
}),
)
}

View File

@ -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)
}

View File

@ -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)
}