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 ( import (
"archive/zip" "archive/zip"
"bytes" "bytes"
"context"
"encoding/base64" "encoding/base64"
"encoding/json" "encoding/json"
"fmt" "fmt"
@ -13,6 +14,8 @@ import (
"text/tabwriter" "text/tabwriter"
"time" "time"
"github.com/coder/coder/v2/cli/cliutil"
"github.com/google/uuid" "github.com/google/uuid"
"golang.org/x/xerrors" "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 details (with environment variable sanitized)
- Agent network diagnostics - Agent network diagnostics
- Agent logs - Agent logs
- License status
` + cliui.Bold("Note: ") + ` + 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.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") + 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") 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: // The below we just write as we have them:
for k, v := range map[string]string{ for k, v := range map[string]string{
"agent/logs.txt": string(src.Agent.Logs), "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, "network/tailnet_debug.html": src.Network.TailnetDebug,
"workspace/build_logs.txt": humanizeBuildLogs(src.Workspace.BuildLogs), "workspace/build_logs.txt": humanizeBuildLogs(src.Workspace.BuildLogs),
"workspace/template_file.zip": string(templateVersionBytes), "workspace/template_file.zip": string(templateVersionBytes),
"license-status.txt": licenseStatus,
} { } {
f, err := dest.Create(k) f, err := dest.Create(k)
if err != nil { if err != nil {
@ -359,3 +369,13 @@ func humanizeBuildLogs(ls []codersdk.ProvisionerJobLog) string {
_ = tw.Flush() _ = tw.Flush()
return buf.String() 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": case "cli_logs.txt":
bs := readBytesFromZip(t, f) bs := readBytesFromZip(t, f)
require.NotEmpty(t, bs, "CLI logs should not be empty") 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: default:
require.Failf(t, "unexpected file in bundle", f.Name) require.Failf(t, "unexpected file in bundle", f.Name)
} }

View File

@ -8,12 +8,11 @@ import (
"regexp" "regexp"
"strconv" "strconv"
"strings" "strings"
"time"
"github.com/google/uuid"
"golang.org/x/xerrors" "golang.org/x/xerrors"
"github.com/coder/coder/v2/cli/cliui" "github.com/coder/coder/v2/cli/cliui"
"github.com/coder/coder/v2/cli/cliutil"
"github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/codersdk"
"github.com/coder/serpent" "github.com/coder/serpent"
) )
@ -137,76 +136,7 @@ func validJWT(s string) error {
} }
func (r *RootCmd) licensesList() *serpent.Command { func (r *RootCmd) licensesList() *serpent.Command {
type tableLicense struct { formatter := cliutil.NewLicenseFormatter()
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
}),
)
client := new(codersdk.Client) client := new(codersdk.Client)
cmd := &serpent.Command{ cmd := &serpent.Command{
Use: "list", Use: "list",

View File

@ -43,6 +43,7 @@ type Deployment struct {
Config *codersdk.DeploymentConfig `json:"config"` Config *codersdk.DeploymentConfig `json:"config"`
Experiments codersdk.Experiments `json:"experiments"` Experiments codersdk.Experiments `json:"experiments"`
HealthReport *healthsdk.HealthcheckReport `json:"health_report"` HealthReport *healthsdk.HealthcheckReport `json:"health_report"`
Licenses []codersdk.License `json:"licenses"`
} }
type Network struct { type Network struct {
@ -138,6 +139,21 @@ func DeploymentInfo(ctx context.Context, client *codersdk.Client, log slog.Logge
return nil 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 { if err := eg.Wait(); err != nil {
log.Error(ctx, "fetch deployment information", slog.Error(err)) log.Error(ctx, "fetch deployment information", slog.Error(err))
} }

View File

@ -62,6 +62,7 @@ func TestRun(t *testing.T) {
assertSanitizedDeploymentConfig(t, bun.Deployment.Config) assertSanitizedDeploymentConfig(t, bun.Deployment.Config)
assertNotNilNotEmpty(t, bun.Deployment.HealthReport, "deployment health report should be present") assertNotNilNotEmpty(t, bun.Deployment.HealthReport, "deployment health report should be present")
assertNotNilNotEmpty(t, bun.Deployment.Experiments, "deployment experiments 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.ConnectionInfo, "agent connection info should be present")
assertNotNilNotEmpty(t, bun.Network.CoordinatorDebug, "network coordinator debug should be present") assertNotNilNotEmpty(t, bun.Network.CoordinatorDebug, "network coordinator debug should be present")
assertNotNilNotEmpty(t, bun.Network.Netcheck, "network netcheck should be present") assertNotNilNotEmpty(t, bun.Network.Netcheck, "network netcheck should be present")