-
Notifications
You must be signed in to change notification settings - Fork 1k
feat(cli): add support cmd #12328
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
feat(cli): add support cmd #12328
Changes from all commits
ab0c40f
397357e
f513cf6
f42a1dd
475eaa8
1d6f29d
764bb67
0a252b1
252e2b6
facd04d
c3e7c7b
9abab12
d76c48c
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,209 @@ | ||
package cli | ||
|
||
import ( | ||
"archive/zip" | ||
"bytes" | ||
"encoding/json" | ||
"fmt" | ||
"os" | ||
"path/filepath" | ||
"strings" | ||
"text/tabwriter" | ||
"time" | ||
|
||
"golang.org/x/xerrors" | ||
|
||
"cdr.dev/slog" | ||
"cdr.dev/slog/sloggers/sloghuman" | ||
"github.com/coder/coder/v2/cli/clibase" | ||
"github.com/coder/coder/v2/codersdk" | ||
"github.com/coder/coder/v2/support" | ||
) | ||
|
||
func (r *RootCmd) support() *clibase.Cmd { | ||
supportCmd := &clibase.Cmd{ | ||
Use: "support", | ||
Short: "Commands for troubleshooting issues with a Coder deployment.", | ||
Handler: func(inv *clibase.Invocation) error { | ||
return inv.Command.HelpHandler(inv) | ||
}, | ||
Hidden: true, // TODO: un-hide once the must-haves from #12160 are completed. | ||
Children: []*clibase.Cmd{ | ||
r.supportBundle(), | ||
}, | ||
} | ||
return supportCmd | ||
} | ||
|
||
func (r *RootCmd) supportBundle() *clibase.Cmd { | ||
var outputPath string | ||
client := new(codersdk.Client) | ||
cmd := &clibase.Cmd{ | ||
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: clibase.Chain( | ||
clibase.RequireRangeArgs(0, 2), | ||
r.InitClient(client), | ||
), | ||
Handler: func(inv *clibase.Invocation) error { | ||
var ( | ||
log = slog.Make(sloghuman.Sink(inv.Stderr)). | ||
Leveled(slog.LevelDebug) | ||
deps = support.Deps{ | ||
Client: client, | ||
Log: log, | ||
} | ||
) | ||
|
||
if len(inv.Args) == 0 { | ||
return xerrors.Errorf("must specify workspace name") | ||
} | ||
ws, err := namedWorkspace(inv.Context(), client, inv.Args[0]) | ||
if err != nil { | ||
return xerrors.Errorf("invalid workspace: %w", err) | ||
} | ||
|
||
deps.WorkspaceID = ws.ID | ||
|
||
agentName := "" | ||
if len(inv.Args) > 1 { | ||
agentName = inv.Args[1] | ||
} | ||
|
||
agt, found := findAgent(agentName, ws.LatestBuild.Resources) | ||
if !found { | ||
return xerrors.Errorf("could not find agent named %q for workspace", agentName) | ||
} | ||
|
||
deps.AgentID = 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) | ||
} | ||
johnstcn marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
||
w, err := os.Create(outputPath) | ||
if err != nil { | ||
return xerrors.Errorf("create output file: %w", err) | ||
} | ||
zwr := zip.NewWriter(w) | ||
defer zwr.Close() | ||
|
||
bun, err := support.Run(inv.Context(), &deps) | ||
if err != nil { | ||
_ = os.Remove(outputPath) // best effort | ||
return xerrors.Errorf("create support bundle: %w", err) | ||
} | ||
|
||
if err := writeBundle(bun, zwr); err != nil { | ||
_ = os.Remove(outputPath) // best effort | ||
return xerrors.Errorf("write support bundle to %s: %w", outputPath, err) | ||
johnstcn marked this conversation as resolved.
Show resolved
Hide resolved
|
||
} | ||
return nil | ||
}, | ||
} | ||
cmd.Options = clibase.OptionSet{ | ||
{ | ||
Flag: "output", | ||
FlagShorthand: "o", | ||
Env: "CODER_SUPPORT_BUNDLE_OUTPUT", | ||
Description: "File path for writing the generated support bundle. Defaults to coder-support-$(date +%s).zip.", | ||
Value: clibase.StringOf(&outputPath), | ||
}, | ||
} | ||
|
||
return cmd | ||
} | ||
|
||
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 { | ||
for k, v := range map[string]any{ | ||
"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/netcheck_local.json": src.Network.NetcheckLocal, | ||
"network/netcheck_remote.json": src.Network.NetcheckRemote, | ||
"workspace/workspace.json": src.Workspace.Workspace, | ||
"workspace/agent.json": src.Workspace.Agent, | ||
} { | ||
f, err := dest.Create(k) | ||
johnstcn marked this conversation as resolved.
Show resolved
Hide resolved
|
||
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) | ||
} | ||
} | ||
|
||
for k, v := range map[string]string{ | ||
"network/coordinator_debug.html": src.Network.CoordinatorDebug, | ||
"network/tailnet_debug.html": src.Network.TailnetDebug, | ||
"workspace/build_logs.txt": humanizeBuildLogs(src.Workspace.BuildLogs), | ||
"workspace/agent_startup_logs.txt": humanizeAgentLogs(src.Workspace.AgentStartupLogs), | ||
"logs.txt": strings.Join(src.Logs, "\n"), | ||
} { | ||
f, err := dest.Create(k) | ||
johnstcn marked this conversation as resolved.
Show resolved
Hide resolved
|
||
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 | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Why do we use an intermediary vs passing in the writer? Perhaps can be resolved by wrapping in funcs. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Tried it, makes things more complicated than I'd like. |
||
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() | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Could do this even before we fetch the workspace from API since it's not used as part of the filename.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think it makes more sense to do the up-front checks first; if the workspace doesn't even exist then there's no point in writing the file.