From ab0c40fead14468faf3ea1393dfe7ad3a3599015 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Tue, 27 Feb 2024 17:52:53 +0000 Subject: [PATCH 01/13] feat(cli): add support command and accompanying unit tests --- cli/root.go | 1 + cli/support.go | 201 +++++++++++++++++++++++ cli/support_test.go | 182 ++++++++++++++++++++ cli/testdata/coder_support_--help.golden | 18 ++ support/support.go | 8 +- 5 files changed, 409 insertions(+), 1 deletion(-) create mode 100644 cli/support.go create mode 100644 cli/support_test.go create mode 100644 cli/testdata/coder_support_--help.golden diff --git a/cli/root.go b/cli/root.go index ab60cda9e32d6..6a9b8367fbeb6 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 0000000000000..7ec5e59263182 --- /dev/null +++ b/cli/support.go @@ -0,0 +1,201 @@ +package cli + +import ( + "archive/zip" + "bytes" + "encoding/json" + "fmt" + "log" + "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/coderd/util/tz" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/support" +) + +func (r *RootCmd) support() *clibase.Cmd { + var outputPath string + client := new(codersdk.Client) + cmd := &clibase.Cmd{ + Use: "support []", + Short: "Generate a support bundle to troubleshoot issues.", + 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 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 + + bun, err := support.Run(inv.Context(), &deps) + if err != nil { + return err + } + + if outputPath == "" { + cwd, err := filepath.Abs(".") + if err != nil { + return xerrors.Errorf("could not determine current working directory: %w", err) + } + loc, err := tz.TimezoneIANA() + if err != nil { + loc = time.UTC + } + tsStr := time.Now().In(loc).Format("2006-01-02-150405") + fname := "coder-support-" + tsStr + ".zip" + 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() + if err := writeBundle(bun, zwr); err != nil { + return xerrors.Errorf("write support bundle to %s: %w", outputPath, err) + } + return nil + }, + Hidden: true, // TODO: un-hide + } + cmd.Options = clibase.OptionSet{ + { + Flag: "output", + FlagShorthand: "o", + Env: "CODER_SUPPORT_BUNDLE_OUTPUT", + Description: "Path to which to output the generated support bundle. Defaults to coder-support-YYYYmmdd-HHMMSS.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) + } + err = json.NewEncoder(f).Encode(v) + if 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 l log.Logger + + var buf bytes.Buffer + l.SetOutput(&buf) + tw := tabwriter.NewWriter(&buf, 0, 2, 1, ' ', 0) + for _, l := range ls { + _, _ = fmt.Fprintf(tw, "%s\t[%s]\t%s\n", + l.CreatedAt.Format(time.RFC3339), + 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(time.RFC3339), + 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 0000000000000..3bbfb76196881 --- /dev/null +++ b/cli/support_test.go @@ -0,0 +1,182 @@ +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 TestSupport(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", 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") + //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", 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", 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 { + 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/cli/testdata/coder_support_--help.golden b/cli/testdata/coder_support_--help.golden new file mode 100644 index 0000000000000..eee56f0178f89 --- /dev/null +++ b/cli/testdata/coder_support_--help.golden @@ -0,0 +1,18 @@ +coder v0.0.0-devel + +USAGE: + coder support [flags] [] + + Generate a support bundle to troubleshoot issues. + + 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). + +OPTIONS: + -o, --output string, $CODER_SUPPORT_BUNDLE_OUTPUT + Path to which to output the generated support bundle. Defaults to + coder-support-YYYYmmdd-HHMMSS.zip. + +——— +Run `coder --help` for a list of global options. diff --git a/support/support.go b/support/support.go index b8eda1f1b98e4..58c9f332298ac 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") From 397357e25af9502fad3d0916714bb8b5c97faef8 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Fri, 1 Mar 2024 13:24:35 +0000 Subject: [PATCH 02/13] indent --- cli/support.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/cli/support.go b/cli/support.go index 7ec5e59263182..776cd3d682030 100644 --- a/cli/support.go +++ b/cli/support.go @@ -140,8 +140,9 @@ func writeBundle(src *support.Bundle, dest *zip.Writer) error { if err != nil { return xerrors.Errorf("create file %q in archive: %w", k, err) } - err = json.NewEncoder(f).Encode(v) - if err != nil { + enc := json.NewEncoder(f) + enc.SetIndent("", " ") + if err := enc.Encode(v); err != nil { return xerrors.Errorf("write json to %q: %w", k, err) } } From f513cf63f30cb961156a6f77d59878a126eee9c2 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Fri, 1 Mar 2024 14:50:32 +0000 Subject: [PATCH 03/13] remove unused log --- cli/support.go | 4 ---- 1 file changed, 4 deletions(-) diff --git a/cli/support.go b/cli/support.go index 776cd3d682030..a013054c57627 100644 --- a/cli/support.go +++ b/cli/support.go @@ -5,7 +5,6 @@ import ( "bytes" "encoding/json" "fmt" - "log" "os" "path/filepath" "strings" @@ -169,10 +168,7 @@ func writeBundle(src *support.Bundle, dest *zip.Writer) error { } func humanizeAgentLogs(ls []codersdk.WorkspaceAgentLog) string { - var l log.Logger - var buf bytes.Buffer - l.SetOutput(&buf) tw := tabwriter.NewWriter(&buf, 0, 2, 1, ' ', 0) for _, l := range ls { _, _ = fmt.Fprintf(tw, "%s\t[%s]\t%s\n", From f42a1dd83a6b7c733cdd5bb9a52df136a20f1f43 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Fri, 1 Mar 2024 14:51:00 +0000 Subject: [PATCH 04/13] use unix ts for filename --- cli/support.go | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/cli/support.go b/cli/support.go index a013054c57627..65450ceefb844 100644 --- a/cli/support.go +++ b/cli/support.go @@ -16,7 +16,6 @@ import ( "cdr.dev/slog" "cdr.dev/slog/sloggers/sloghuman" "github.com/coder/coder/v2/cli/clibase" - "github.com/coder/coder/v2/coderd/util/tz" "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/support" ) @@ -74,12 +73,7 @@ func (r *RootCmd) support() *clibase.Cmd { if err != nil { return xerrors.Errorf("could not determine current working directory: %w", err) } - loc, err := tz.TimezoneIANA() - if err != nil { - loc = time.UTC - } - tsStr := time.Now().In(loc).Format("2006-01-02-150405") - fname := "coder-support-" + tsStr + ".zip" + fname := fmt.Sprintf("coder-support-%d.zip", time.Now().Unix()) outputPath = filepath.Join(cwd, fname) } @@ -101,7 +95,7 @@ func (r *RootCmd) support() *clibase.Cmd { Flag: "output", FlagShorthand: "o", Env: "CODER_SUPPORT_BUNDLE_OUTPUT", - Description: "Path to which to output the generated support bundle. Defaults to coder-support-YYYYmmdd-HHMMSS.zip.", + Description: "File path for writing the generated support bundle. Defaults to coder-support-$(date +%s).zip.", Value: clibase.StringOf(&outputPath), }, } From 475eaa87c36fb13148d5b52353056b516f35d0cf Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Fri, 1 Mar 2024 14:51:19 +0000 Subject: [PATCH 05/13] gen bundle once file created --- cli/support.go | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/cli/support.go b/cli/support.go index 65450ceefb844..150e7730f23fb 100644 --- a/cli/support.go +++ b/cli/support.go @@ -63,11 +63,6 @@ func (r *RootCmd) support() *clibase.Cmd { deps.AgentID = agt.ID - bun, err := support.Run(inv.Context(), &deps) - if err != nil { - return err - } - if outputPath == "" { cwd, err := filepath.Abs(".") if err != nil { @@ -83,6 +78,13 @@ func (r *RootCmd) support() *clibase.Cmd { } 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 { return xerrors.Errorf("write support bundle to %s: %w", outputPath, err) } From 1d6f29d150095a612924e5e5ecddd2fe25af4b3b Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Fri, 1 Mar 2024 14:51:48 +0000 Subject: [PATCH 06/13] rm temp file on failure --- cli/support.go | 1 + 1 file changed, 1 insertion(+) diff --git a/cli/support.go b/cli/support.go index 150e7730f23fb..585f021b06e21 100644 --- a/cli/support.go +++ b/cli/support.go @@ -86,6 +86,7 @@ func (r *RootCmd) support() *clibase.Cmd { } 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 From 764bb67967850e43bc9e2f75e25bf45f581914de Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Fri, 1 Mar 2024 14:51:59 +0000 Subject: [PATCH 07/13] add comment on hidden --- cli/support.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cli/support.go b/cli/support.go index 585f021b06e21..d3b2e21ec14ca 100644 --- a/cli/support.go +++ b/cli/support.go @@ -91,7 +91,7 @@ func (r *RootCmd) support() *clibase.Cmd { } return nil }, - Hidden: true, // TODO: un-hide + Hidden: true, // TODO: un-hide once the must-haves from #12160 are completed. } cmd.Options = clibase.OptionSet{ { From 0a252b1175f4111b74527db2121e7e910d56f562 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Fri, 1 Mar 2024 14:52:06 +0000 Subject: [PATCH 08/13] check for non-empty file --- cli/support_test.go | 1 + 1 file changed, 1 insertion(+) diff --git a/cli/support_test.go b/cli/support_test.go index 3bbfb76196881..464b245f4fa8a 100644 --- a/cli/support_test.go +++ b/cli/support_test.go @@ -114,6 +114,7 @@ func assertBundleContents(t *testing.T, path string) { 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 From 252e2b629494191e2b2b630025a29556ca16938b Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Fri, 1 Mar 2024 14:53:21 +0000 Subject: [PATCH 09/13] wrap err --- cli/support.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cli/support.go b/cli/support.go index d3b2e21ec14ca..e6965175b6739 100644 --- a/cli/support.go +++ b/cli/support.go @@ -46,7 +46,7 @@ func (r *RootCmd) support() *clibase.Cmd { } ws, err := namedWorkspace(inv.Context(), client, inv.Args[0]) if err != nil { - return err + return xerrors.Errorf("invalid workspace: %w", err) } deps.WorkspaceID = ws.ID From facd04d2fe5ff6cb9e9c66a5656eb56459b8a57c Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Fri, 1 Mar 2024 15:00:00 +0000 Subject: [PATCH 10/13] use slog time format for humanizing logs --- cli/support.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cli/support.go b/cli/support.go index e6965175b6739..ffc36752cdb05 100644 --- a/cli/support.go +++ b/cli/support.go @@ -169,7 +169,7 @@ func humanizeAgentLogs(ls []codersdk.WorkspaceAgentLog) string { tw := tabwriter.NewWriter(&buf, 0, 2, 1, ' ', 0) for _, l := range ls { _, _ = fmt.Fprintf(tw, "%s\t[%s]\t%s\n", - l.CreatedAt.Format(time.RFC3339), + l.CreatedAt.Format("2006-01-02 15:04:05.000"), // for consistency with slog string(l.Level), l.Output, ) @@ -183,7 +183,7 @@ func humanizeBuildLogs(ls []codersdk.ProvisionerJobLog) string { 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(time.RFC3339), + l.CreatedAt.Format("2006-01-02 15:04:05.000"), // for consistency with slog string(l.Level), string(l.Source), l.Stage, From c3e7c7b38db20012570d0b06f3b8c1b5a2c0cc51 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Fri, 1 Mar 2024 15:06:29 +0000 Subject: [PATCH 11/13] rename command --- cli/support.go | 20 +++++++++++++++++--- cli/support_test.go | 10 +++++----- 2 files changed, 22 insertions(+), 8 deletions(-) diff --git a/cli/support.go b/cli/support.go index ffc36752cdb05..147468f7a1aa6 100644 --- a/cli/support.go +++ b/cli/support.go @@ -21,11 +21,26 @@ import ( ) func (r *RootCmd) support() *clibase.Cmd { + supportCmd := &clibase.Cmd{ + Use: "support { dump }", + 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: "support []", - Short: "Generate a support bundle to troubleshoot issues.", + 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), @@ -91,7 +106,6 @@ func (r *RootCmd) support() *clibase.Cmd { } return nil }, - Hidden: true, // TODO: un-hide once the must-haves from #12160 are completed. } cmd.Options = clibase.OptionSet{ { diff --git a/cli/support_test.go b/cli/support_test.go index 464b245f4fa8a..41e2f6df6d98d 100644 --- a/cli/support_test.go +++ b/cli/support_test.go @@ -19,7 +19,7 @@ import ( "github.com/coder/coder/v2/testutil" ) -func TestSupport(t *testing.T) { +func TestSupportBundle(t *testing.T) { t.Parallel() t.Run("Workspace", func(t *testing.T) { @@ -58,7 +58,7 @@ func TestSupport(t *testing.T) { d := t.TempDir() path := filepath.Join(d, "bundle.zip") - inv, root := clitest.New(t, "support", r.Workspace.Name, "--output", path) + 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() @@ -70,7 +70,7 @@ func TestSupport(t *testing.T) { t.Parallel() client := coderdtest.New(t, nil) _ = coderdtest.CreateFirstUser(t, client) - inv, root := clitest.New(t, "support") + inv, root := clitest.New(t, "support", "bundle") //nolint: gocritic // requires owner privilege clitest.SetupConfig(t, client, root) err := inv.Run() @@ -85,7 +85,7 @@ func TestSupport(t *testing.T) { OrganizationID: admin.OrganizationID, OwnerID: admin.UserID, }).Do() // without agent! - inv, root := clitest.New(t, "support", r.Workspace.Name) + inv, root := clitest.New(t, "support", "bundle", r.Workspace.Name) //nolint: gocritic // requires owner privilege clitest.SetupConfig(t, client, root) err := inv.Run() @@ -101,7 +101,7 @@ func TestSupport(t *testing.T) { OrganizationID: user.OrganizationID, OwnerID: member.ID, }).WithAgent().Do() - inv, root := clitest.New(t, "support", r.Workspace.Name) + 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") From 9abab12dc9d15e57d62251625390b803a71edf11 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Fri, 1 Mar 2024 16:09:43 +0000 Subject: [PATCH 12/13] rm unncessary file --- cli/testdata/coder_support_--help.golden | 18 ------------------ 1 file changed, 18 deletions(-) delete mode 100644 cli/testdata/coder_support_--help.golden diff --git a/cli/testdata/coder_support_--help.golden b/cli/testdata/coder_support_--help.golden deleted file mode 100644 index eee56f0178f89..0000000000000 --- a/cli/testdata/coder_support_--help.golden +++ /dev/null @@ -1,18 +0,0 @@ -coder v0.0.0-devel - -USAGE: - coder support [flags] [] - - Generate a support bundle to troubleshoot issues. - - 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). - -OPTIONS: - -o, --output string, $CODER_SUPPORT_BUNDLE_OUTPUT - Path to which to output the generated support bundle. Defaults to - coder-support-YYYYmmdd-HHMMSS.zip. - -——— -Run `coder --help` for a list of global options. From d76c48c94ac269f766a6159657f28e298d862696 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Fri, 1 Mar 2024 17:04:12 +0000 Subject: [PATCH 13/13] fix usage --- cli/support.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cli/support.go b/cli/support.go index 147468f7a1aa6..e2d3019950ead 100644 --- a/cli/support.go +++ b/cli/support.go @@ -22,7 +22,7 @@ import ( func (r *RootCmd) support() *clibase.Cmd { supportCmd := &clibase.Cmd{ - Use: "support { dump }", + Use: "support", Short: "Commands for troubleshooting issues with a Coder deployment.", Handler: func(inv *clibase.Invocation) error { return inv.Command.HelpHandler(inv)