From b1c2fea78bf4028f883ed0bde26732d365f49efa Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Fri, 1 Mar 2024 17:13:50 +0000 Subject: [PATCH] feat(cli): add support cmd (#12328) Part of #12163 - Adds a command coder support bundle that generates a support bundle and writes it to coder-support-$(date +%s).zip. - Note: this is hidden currently until the rest of the functionality is fleshed out. --- cli/root.go | 1 + cli/support.go | 209 ++++++++++++++++++++++++++++++++++++++++++++ cli/support_test.go | 183 ++++++++++++++++++++++++++++++++++++++ support/support.go | 8 +- 4 files changed, 400 insertions(+), 1 deletion(-) create mode 100644 cli/support.go create mode 100644 cli/support_test.go diff --git a/cli/root.go b/cli/root.go index ab60cda9e3..6a9b8367fb 100644 --- a/cli/root.go +++ b/cli/root.go @@ -123,6 +123,7 @@ func (r *RootCmd) Core() []*clibase.Cmd { r.vscodeSSH(), r.workspaceAgent(), r.expCmd(), + r.support(), } } diff --git a/cli/support.go b/cli/support.go new file mode 100644 index 0000000000..e2d3019950 --- /dev/null +++ b/cli/support.go @@ -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 []", + 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) + } + + 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) + } + 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) + 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) + 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() +} diff --git a/cli/support_test.go b/cli/support_test.go new file mode 100644 index 0000000000..41e2f6df6d --- /dev/null +++ b/cli/support_test.go @@ -0,0 +1,183 @@ +package cli_test + +import ( + "archive/zip" + "encoding/json" + "io" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/coder/coder/v2/cli/clitest" + "github.com/coder/coder/v2/coderd/coderdtest" + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/dbfake" + "github.com/coder/coder/v2/coderd/database/dbtime" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/testutil" +) + +func TestSupportBundle(t *testing.T) { + t.Parallel() + + t.Run("Workspace", func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitShort) + client, db := coderdtest.NewWithDatabase(t, nil) + owner := coderdtest.CreateFirstUser(t, client) + r := dbfake.WorkspaceBuild(t, db, database.Workspace{ + OrganizationID: owner.OrganizationID, + OwnerID: owner.UserID, + }).WithAgent().Do() + ws, err := client.Workspace(ctx, r.Workspace.ID) + require.NoError(t, err) + agt := ws.LatestBuild.Resources[0].Agents[0] + + // Insert a provisioner job log + _, err = db.InsertProvisionerJobLogs(ctx, database.InsertProvisionerJobLogsParams{ + JobID: r.Build.JobID, + CreatedAt: []time.Time{dbtime.Now()}, + Source: []database.LogSource{database.LogSourceProvisionerDaemon}, + Level: []database.LogLevel{database.LogLevelInfo}, + Stage: []string{"provision"}, + Output: []string{"done"}, + }) + require.NoError(t, err) + // Insert an agent log + _, err = db.InsertWorkspaceAgentLogs(ctx, database.InsertWorkspaceAgentLogsParams{ + AgentID: agt.ID, + CreatedAt: dbtime.Now(), + Output: []string{"started up"}, + Level: []database.LogLevel{database.LogLevelInfo}, + LogSourceID: r.Build.JobID, + OutputLength: 10, + }) + require.NoError(t, err) + + d := t.TempDir() + path := filepath.Join(d, "bundle.zip") + inv, root := clitest.New(t, "support", "bundle", r.Workspace.Name, "--output", path) + //nolint: gocritic // requires owner privilege + clitest.SetupConfig(t, client, root) + err = inv.Run() + require.NoError(t, err) + assertBundleContents(t, path) + }) + + t.Run("NoWorkspace", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, nil) + _ = coderdtest.CreateFirstUser(t, client) + inv, root := clitest.New(t, "support", "bundle") + //nolint: gocritic // requires owner privilege + clitest.SetupConfig(t, client, root) + err := inv.Run() + require.ErrorContains(t, err, "must specify workspace name") + }) + + t.Run("NoAgent", func(t *testing.T) { + t.Parallel() + client, db := coderdtest.NewWithDatabase(t, nil) + admin := coderdtest.CreateFirstUser(t, client) + r := dbfake.WorkspaceBuild(t, db, database.Workspace{ + OrganizationID: admin.OrganizationID, + OwnerID: admin.UserID, + }).Do() // without agent! + inv, root := clitest.New(t, "support", "bundle", r.Workspace.Name) + //nolint: gocritic // requires owner privilege + clitest.SetupConfig(t, client, root) + err := inv.Run() + require.ErrorContains(t, err, "could not find agent") + }) + + t.Run("NoPrivilege", func(t *testing.T) { + t.Parallel() + client, db := coderdtest.NewWithDatabase(t, nil) + user := coderdtest.CreateFirstUser(t, client) + memberClient, member := coderdtest.CreateAnotherUser(t, client, user.OrganizationID) + r := dbfake.WorkspaceBuild(t, db, database.Workspace{ + OrganizationID: user.OrganizationID, + OwnerID: member.ID, + }).WithAgent().Do() + inv, root := clitest.New(t, "support", "bundle", r.Workspace.Name) + clitest.SetupConfig(t, memberClient, root) + err := inv.Run() + require.ErrorContains(t, err, "failed authorization check") + }) +} + +func assertBundleContents(t *testing.T, path string) { + t.Helper() + r, err := zip.OpenReader(path) + require.NoError(t, err, "open zip file") + defer r.Close() + for _, f := range r.File { + require.NotZero(t, f.UncompressedSize64, "file %q should not be empty", f.Name) + switch f.Name { + case "deployment/buildinfo.json": + var v codersdk.BuildInfoResponse + decodeJSONFromZip(t, f, &v) + require.NotEmpty(t, v, "deployment build info should not be empty") + case "deployment/config.json": + var v codersdk.DeploymentConfig + decodeJSONFromZip(t, f, &v) + require.NotEmpty(t, v, "deployment config should not be empty") + case "deployment/experiments.json": + var v codersdk.Experiments + decodeJSONFromZip(t, f, &v) + require.NotEmpty(t, f, v, "experiments should not be empty") + case "deployment/health.json": + var v codersdk.HealthcheckReport + decodeJSONFromZip(t, f, &v) + require.NotEmpty(t, v, "health report should not be empty") + case "network/coordinator_debug.html": + bs := readBytesFromZip(t, f) + require.NotEmpty(t, bs, "coordinator debug should not be empty") + case "network/tailnet_debug.html": + bs := readBytesFromZip(t, f) + require.NotEmpty(t, bs, "tailnet debug should not be empty") + case "network/netcheck_local.json", "network/netcheck_remote.json": + // TODO: setup fake agent? + bs := readBytesFromZip(t, f) + require.NotEmpty(t, bs, "netcheck should not be empty") + case "workspace/workspace.json": + var v codersdk.Workspace + decodeJSONFromZip(t, f, &v) + require.NotEmpty(t, v, "workspace should not be empty") + case "workspace/build_logs.txt": + bs := readBytesFromZip(t, f) + require.Contains(t, string(bs), "provision done") + case "workspace/agent.json": + var v codersdk.WorkspaceAgent + decodeJSONFromZip(t, f, &v) + require.NotEmpty(t, v, "agent should not be empty") + case "workspace/agent_startup_logs.txt": + bs := readBytesFromZip(t, f) + require.Contains(t, string(bs), "started up") + case "logs.txt": + bs := readBytesFromZip(t, f) + require.NotEmpty(t, bs, "logs should not be empty") + default: + require.Fail(t, "unexpected file in bundle", f.Name) + } + } +} + +func decodeJSONFromZip(t *testing.T, f *zip.File, dest any) { + t.Helper() + rc, err := f.Open() + require.NoError(t, err, "open file from zip") + defer rc.Close() + require.NoError(t, json.NewDecoder(rc).Decode(&dest)) +} + +func readBytesFromZip(t *testing.T, f *zip.File) []byte { + t.Helper() + rc, err := f.Open() + require.NoError(t, err, "open file from zip") + bs, err := io.ReadAll(rc) + require.NoError(t, err, "read bytes from zip") + return bs +} diff --git a/support/support.go b/support/support.go index b8eda1f1b9..58c9f33229 100644 --- a/support/support.go +++ b/support/support.go @@ -155,7 +155,13 @@ func WorkspaceInfo(ctx context.Context, client *codersdk.Client, log slog.Logger return w } + agt, err := client.WorkspaceAgent(ctx, agentID) + if err != nil { + log.Error(ctx, "fetch workspace agent", slog.Error(err), slog.F("agent_id", agentID)) + } + w.Workspace = ws + w.Agent = agt buildLogCh, closer, err := client.WorkspaceBuildLogsAfter(ctx, ws.LatestBuild.ID, 0) if err != nil { @@ -203,7 +209,7 @@ func Run(ctx context.Context, d *Deps) (*Bundle, error) { // Ensure we capture logs from the client. var logw strings.Builder - d.Log.AppendSinks(sloghuman.Sink(&logw)) + d.Log = d.Log.AppendSinks(sloghuman.Sink(&logw)) d.Client.SetLogger(d.Log) defer func() { b.Logs = strings.Split(logw.String(), "\n")