mirror of
https://github.com/coder/coder.git
synced 2026-06-02 20:48:20 +00:00
bddb808b25
Fixes all our Go file imports to match the preferred spec that we've _mostly_ been using. For example: ``` import ( "context" "time" "github.com/prometheus/client_golang/prometheus" "golang.org/x/xerrors" "gopkg.in/natefinch/lumberjack.v2" "cdr.dev/slog/v3" "github.com/coder/coder/v2/codersdk/agentsdk" "github.com/coder/serpent" ) ``` 3 groups: standard library, 3rd partly libs, Coder libs. This PR makes the change across the codebase. The PR in the stack above modifies our formatting to maintain this state of affairs, and is a separate PR so it's possible to review that one in detail.
383 lines
12 KiB
Go
383 lines
12 KiB
Go
package cli
|
|
|
|
import (
|
|
"archive/zip"
|
|
"bytes"
|
|
"context"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"text/tabwriter"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"golang.org/x/xerrors"
|
|
|
|
"cdr.dev/slog/v3"
|
|
"cdr.dev/slog/v3/sloggers/sloghuman"
|
|
"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/support"
|
|
"github.com/coder/serpent"
|
|
)
|
|
|
|
func (r *RootCmd) support() *serpent.Command {
|
|
supportCmd := &serpent.Command{
|
|
Use: "support",
|
|
Short: "Commands for troubleshooting issues with a Coder deployment.",
|
|
Handler: func(inv *serpent.Invocation) error {
|
|
return inv.Command.HelpHandler(inv)
|
|
},
|
|
Children: []*serpent.Command{
|
|
r.supportBundle(),
|
|
},
|
|
}
|
|
return supportCmd
|
|
}
|
|
|
|
var supportBundleBlurb = cliui.Bold("This will collect the following information:\n") +
|
|
` - Coder deployment version
|
|
- Coder deployment Configuration (sanitized), including enabled experiments
|
|
- Coder deployment health snapshot
|
|
- Coder deployment Network troubleshooting information
|
|
- Workspace configuration, parameters, and build logs
|
|
- Template version and source code for the given workspace
|
|
- 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") +
|
|
" - Review the support bundle before distribution\n" +
|
|
" - Only distribute it via trusted channels\n" +
|
|
cliui.Bold("Continue? ")
|
|
|
|
func (r *RootCmd) supportBundle() *serpent.Command {
|
|
var outputPath string
|
|
var coderURLOverride string
|
|
cmd := &serpent.Command{
|
|
Use: "bundle <workspace> [<agent>]",
|
|
Short: "Generate a support bundle to troubleshoot issues connecting to a workspace.",
|
|
Long: `This command generates a file containing detailed troubleshooting information about the Coder deployment and workspace connections. You must specify a single workspace (and optionally an agent name).`,
|
|
Middleware: serpent.Chain(
|
|
serpent.RequireRangeArgs(0, 2),
|
|
),
|
|
Handler: func(inv *serpent.Invocation) error {
|
|
client, err := r.InitClient(inv)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
var cliLogBuf bytes.Buffer
|
|
cliLogW := sloghuman.Sink(&cliLogBuf)
|
|
cliLog := slog.Make(cliLogW).Leveled(slog.LevelDebug)
|
|
if r.verbose {
|
|
cliLog = cliLog.AppendSinks(sloghuman.Sink(inv.Stderr))
|
|
}
|
|
ans, err := cliui.Prompt(inv, cliui.PromptOptions{
|
|
Text: supportBundleBlurb,
|
|
Secret: false,
|
|
IsConfirm: true,
|
|
})
|
|
if err != nil || ans != cliui.ConfirmYes {
|
|
return err
|
|
}
|
|
if skip, _ := inv.ParsedFlags().GetBool("yes"); skip {
|
|
cliLog.Debug(inv.Context(), "user auto-confirmed")
|
|
} else {
|
|
cliLog.Debug(inv.Context(), "user confirmed manually", slog.F("answer", ans))
|
|
}
|
|
|
|
vi := defaultVersionInfo()
|
|
cliLog.Debug(inv.Context(), "version info",
|
|
slog.F("version", vi.Version),
|
|
slog.F("build_time", vi.BuildTime),
|
|
slog.F("external_url", vi.ExternalURL),
|
|
slog.F("slim", vi.Slim),
|
|
slog.F("agpl", vi.AGPL),
|
|
slog.F("boring_crypto", vi.BoringCrypto),
|
|
)
|
|
cliLog.Debug(inv.Context(), "invocation", slog.F("args", strings.Join(os.Args, " ")))
|
|
|
|
// Check if we're running inside a workspace
|
|
if val, found := os.LookupEnv("CODER"); found && val == "true" {
|
|
cliui.Warn(inv.Stderr, "Running inside Coder workspace; this can affect results!")
|
|
cliLog.Debug(inv.Context(), "running inside coder workspace")
|
|
}
|
|
|
|
if coderURLOverride != "" && coderURLOverride != client.URL.String() {
|
|
u, err := url.Parse(coderURLOverride)
|
|
if err != nil {
|
|
return xerrors.Errorf("invalid value for Coder URL override: %w", err)
|
|
}
|
|
_, _ = fmt.Fprintf(inv.Stderr, "Overrode Coder URL to %q; this can affect results!\n", coderURLOverride)
|
|
cliLog.Debug(inv.Context(), "coder url overridden", slog.F("url", coderURLOverride))
|
|
client.URL = u
|
|
}
|
|
|
|
var (
|
|
wsID uuid.UUID
|
|
agtID uuid.UUID
|
|
)
|
|
|
|
if len(inv.Args) == 0 {
|
|
cliLog.Warn(inv.Context(), "no workspace specified")
|
|
cliui.Warn(inv.Stderr, "No workspace specified. This will result in incomplete information.")
|
|
} else {
|
|
ws, err := namedWorkspace(inv.Context(), client, inv.Args[0])
|
|
if err != nil {
|
|
return xerrors.Errorf("invalid workspace: %w", err)
|
|
}
|
|
cliLog.Debug(inv.Context(), "found workspace",
|
|
slog.F("workspace_name", ws.Name),
|
|
slog.F("workspace_id", ws.ID),
|
|
)
|
|
wsID = ws.ID
|
|
agentName := ""
|
|
if len(inv.Args) > 1 {
|
|
agentName = inv.Args[1]
|
|
}
|
|
|
|
agt, found := findAgent(agentName, ws.LatestBuild.Resources)
|
|
if !found {
|
|
cliLog.Warn(inv.Context(), "could not find agent in workspace", slog.F("agent_name", agentName))
|
|
} else {
|
|
cliLog.Debug(inv.Context(), "found workspace agent",
|
|
slog.F("agent_name", agt.Name),
|
|
slog.F("agent_id", agt.ID),
|
|
)
|
|
agtID = agt.ID
|
|
}
|
|
}
|
|
|
|
if outputPath == "" {
|
|
cwd, err := filepath.Abs(".")
|
|
if err != nil {
|
|
return xerrors.Errorf("could not determine current working directory: %w", err)
|
|
}
|
|
fname := fmt.Sprintf("coder-support-%d.zip", time.Now().Unix())
|
|
outputPath = filepath.Join(cwd, fname)
|
|
}
|
|
cliLog.Debug(inv.Context(), "output path", slog.F("path", outputPath))
|
|
|
|
w, err := os.Create(outputPath)
|
|
if err != nil {
|
|
return xerrors.Errorf("create output file: %w", err)
|
|
}
|
|
zwr := zip.NewWriter(w)
|
|
defer zwr.Close()
|
|
|
|
clientLog := slog.Make().Leveled(slog.LevelDebug)
|
|
if r.verbose {
|
|
clientLog.AppendSinks(sloghuman.Sink(inv.Stderr))
|
|
}
|
|
deps := support.Deps{
|
|
Client: client,
|
|
// Support adds a sink so we don't need to supply one ourselves.
|
|
Log: clientLog,
|
|
WorkspaceID: wsID,
|
|
AgentID: agtID,
|
|
}
|
|
|
|
bun, err := support.Run(inv.Context(), &deps)
|
|
if err != nil {
|
|
_ = os.Remove(outputPath) // best effort
|
|
return xerrors.Errorf("create support bundle: %w", err)
|
|
}
|
|
|
|
summarizeBundle(inv, bun)
|
|
bun.CLILogs = cliLogBuf.Bytes()
|
|
|
|
if err := writeBundle(bun, zwr); err != nil {
|
|
_ = os.Remove(outputPath) // best effort
|
|
return xerrors.Errorf("write support bundle to %s: %w", outputPath, err)
|
|
}
|
|
_, _ = fmt.Fprintln(inv.Stderr, "Wrote support bundle to "+outputPath)
|
|
|
|
return nil
|
|
},
|
|
}
|
|
cmd.Options = serpent.OptionSet{
|
|
cliui.SkipPromptOption(),
|
|
{
|
|
Flag: "output-file",
|
|
FlagShorthand: "O",
|
|
Env: "CODER_SUPPORT_BUNDLE_OUTPUT_FILE",
|
|
Description: "File path for writing the generated support bundle. Defaults to coder-support-$(date +%s).zip.",
|
|
Value: serpent.StringOf(&outputPath),
|
|
},
|
|
{
|
|
Flag: "url-override",
|
|
Env: "CODER_SUPPORT_BUNDLE_URL_OVERRIDE",
|
|
Description: "Override the URL to your Coder deployment. This may be useful, for example, if you need to troubleshoot a specific Coder replica.",
|
|
Value: serpent.StringOf(&coderURLOverride),
|
|
},
|
|
}
|
|
|
|
return cmd
|
|
}
|
|
|
|
// summarizeBundle makes a best-effort attempt to write a short summary
|
|
// of the support bundle to the user's terminal.
|
|
func summarizeBundle(inv *serpent.Invocation, bun *support.Bundle) {
|
|
if bun == nil {
|
|
cliui.Error(inv.Stdout, "No support bundle generated!")
|
|
return
|
|
}
|
|
|
|
if bun.Deployment.Config == nil {
|
|
cliui.Error(inv.Stdout, "No deployment configuration available!")
|
|
return
|
|
}
|
|
|
|
docsURL := bun.Deployment.Config.Values.DocsURL.String()
|
|
if bun.Deployment.HealthReport == nil {
|
|
cliui.Error(inv.Stdout, "No deployment health report available!")
|
|
return
|
|
}
|
|
deployHealthSummary := bun.Deployment.HealthReport.Summarize(docsURL)
|
|
if len(deployHealthSummary) > 0 {
|
|
cliui.Warn(inv.Stdout, "Deployment health issues detected:", deployHealthSummary...)
|
|
}
|
|
|
|
if bun.Network.Netcheck == nil {
|
|
cliui.Error(inv.Stdout, "No network troubleshooting information available!")
|
|
return
|
|
}
|
|
|
|
clientNetcheckSummary := bun.Network.Netcheck.Summarize("Client netcheck:", docsURL)
|
|
if len(clientNetcheckSummary) > 0 {
|
|
cliui.Warn(inv.Stdout, "Networking issues detected:", clientNetcheckSummary...)
|
|
}
|
|
}
|
|
|
|
func findAgent(agentName string, haystack []codersdk.WorkspaceResource) (*codersdk.WorkspaceAgent, bool) {
|
|
for _, res := range haystack {
|
|
for _, agt := range res.Agents {
|
|
if agentName == "" {
|
|
// just return the first
|
|
return &agt, true
|
|
}
|
|
if agt.Name == agentName {
|
|
return &agt, true
|
|
}
|
|
}
|
|
}
|
|
return nil, false
|
|
}
|
|
|
|
func writeBundle(src *support.Bundle, dest *zip.Writer) error {
|
|
// We JSON-encode the following:
|
|
for k, v := range map[string]any{
|
|
"agent/agent.json": src.Agent.Agent,
|
|
"agent/listening_ports.json": src.Agent.ListeningPorts,
|
|
"agent/manifest.json": src.Agent.Manifest,
|
|
"agent/peer_diagnostics.json": src.Agent.PeerDiagnostics,
|
|
"agent/ping_result.json": src.Agent.PingResult,
|
|
"deployment/buildinfo.json": src.Deployment.BuildInfo,
|
|
"deployment/config.json": src.Deployment.Config,
|
|
"deployment/experiments.json": src.Deployment.Experiments,
|
|
"deployment/health.json": src.Deployment.HealthReport,
|
|
"network/connection_info.json": src.Network.ConnectionInfo,
|
|
"network/netcheck.json": src.Network.Netcheck,
|
|
"network/interfaces.json": src.Network.Interfaces,
|
|
"workspace/template.json": src.Workspace.Template,
|
|
"workspace/template_version.json": src.Workspace.TemplateVersion,
|
|
"workspace/parameters.json": src.Workspace.Parameters,
|
|
"workspace/workspace.json": src.Workspace.Workspace,
|
|
} {
|
|
f, err := dest.Create(k)
|
|
if err != nil {
|
|
return xerrors.Errorf("create file %q in archive: %w", k, err)
|
|
}
|
|
enc := json.NewEncoder(f)
|
|
enc.SetIndent("", " ")
|
|
if err := enc.Encode(v); err != nil {
|
|
return xerrors.Errorf("write json to %q: %w", k, err)
|
|
}
|
|
}
|
|
|
|
templateVersionBytes, err := base64.StdEncoding.DecodeString(src.Workspace.TemplateFileBase64)
|
|
if err != nil {
|
|
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),
|
|
"agent/agent_magicsock.html": string(src.Agent.AgentMagicsockHTML),
|
|
"agent/client_magicsock.html": string(src.Agent.ClientMagicsockHTML),
|
|
"agent/startup_logs.txt": humanizeAgentLogs(src.Agent.StartupLogs),
|
|
"agent/prometheus.txt": string(src.Agent.Prometheus),
|
|
"cli_logs.txt": string(src.CLILogs),
|
|
"logs.txt": strings.Join(src.Logs, "\n"),
|
|
"network/coordinator_debug.html": src.Network.CoordinatorDebug,
|
|
"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 {
|
|
return xerrors.Errorf("create file %q in archive: %w", k, err)
|
|
}
|
|
if _, err := f.Write([]byte(v)); err != nil {
|
|
return xerrors.Errorf("write file %q in archive: %w", k, err)
|
|
}
|
|
}
|
|
if err := dest.Close(); err != nil {
|
|
return xerrors.Errorf("close zip file: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func humanizeAgentLogs(ls []codersdk.WorkspaceAgentLog) string {
|
|
var buf bytes.Buffer
|
|
tw := tabwriter.NewWriter(&buf, 0, 2, 1, ' ', 0)
|
|
for _, l := range ls {
|
|
_, _ = fmt.Fprintf(tw, "%s\t[%s]\t%s\n",
|
|
l.CreatedAt.Format("2006-01-02 15:04:05.000"), // for consistency with slog
|
|
string(l.Level),
|
|
l.Output,
|
|
)
|
|
}
|
|
_ = tw.Flush()
|
|
return buf.String()
|
|
}
|
|
|
|
func humanizeBuildLogs(ls []codersdk.ProvisionerJobLog) string {
|
|
var buf bytes.Buffer
|
|
tw := tabwriter.NewWriter(&buf, 0, 2, 1, ' ', 0)
|
|
for _, l := range ls {
|
|
_, _ = fmt.Fprintf(tw, "%s\t[%s]\t%s\t%s\t%s\n",
|
|
l.CreatedAt.Format("2006-01-02 15:04:05.000"), // for consistency with slog
|
|
string(l.Level),
|
|
string(l.Source),
|
|
l.Stage,
|
|
l.Output,
|
|
)
|
|
}
|
|
_ = 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)
|
|
}
|