Skip to content

Commit a6a89c9

Browse files
committed
feat(cli): add support command and accompanying unit tests
1 parent 7fbca62 commit a6a89c9

File tree

9 files changed

+429
-1
lines changed

9 files changed

+429
-1
lines changed

cli/root.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,7 @@ func (r *RootCmd) Core() []*clibase.Cmd {
123123
r.vscodeSSH(),
124124
r.workspaceAgent(),
125125
r.expCmd(),
126+
r.support(),
126127
}
127128
}
128129

cli/support.go

Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,202 @@
1+
package cli
2+
3+
import (
4+
"archive/zip"
5+
"bytes"
6+
"encoding/json"
7+
"fmt"
8+
"log"
9+
"os"
10+
"path/filepath"
11+
"strings"
12+
"text/tabwriter"
13+
"time"
14+
15+
"golang.org/x/xerrors"
16+
17+
"cdr.dev/slog"
18+
"cdr.dev/slog/sloggers/sloghuman"
19+
"github.com/coder/coder/v2/cli/clibase"
20+
"github.com/coder/coder/v2/coderd/util/tz"
21+
"github.com/coder/coder/v2/codersdk"
22+
"github.com/coder/coder/v2/support"
23+
)
24+
25+
func (r *RootCmd) support() *clibase.Cmd {
26+
var (
27+
outputPath string
28+
)
29+
client := new(codersdk.Client)
30+
cmd := &clibase.Cmd{
31+
Use: "support <workspace> [<agent>]",
32+
Short: "Generate a support bundle to troubleshoot issues.",
33+
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).`,
34+
Middleware: clibase.Chain(
35+
clibase.RequireRangeArgs(0, 2),
36+
r.InitClient(client),
37+
),
38+
Handler: func(inv *clibase.Invocation) error {
39+
var (
40+
log = slog.Make(sloghuman.Sink(inv.Stderr)).
41+
Leveled(slog.LevelDebug)
42+
deps = support.Deps{
43+
Client: client,
44+
Log: log,
45+
}
46+
)
47+
48+
if len(inv.Args) == 0 {
49+
return xerrors.Errorf("must specify workspace name")
50+
}
51+
ws, err := namedWorkspace(inv.Context(), client, inv.Args[0])
52+
if err != nil {
53+
return err
54+
}
55+
56+
deps.WorkspaceID = ws.ID
57+
58+
agentName := ""
59+
if len(inv.Args) > 1 {
60+
agentName = inv.Args[1]
61+
}
62+
63+
agt, found := findAgent(agentName, ws.LatestBuild.Resources)
64+
if !found {
65+
return xerrors.Errorf("could not find agent named %q for workspace", agentName)
66+
}
67+
68+
deps.AgentID = agt.ID
69+
70+
bun, err := support.Run(inv.Context(), &deps)
71+
if err != nil {
72+
return err
73+
}
74+
75+
if outputPath == "" {
76+
cwd, err := filepath.Abs(".")
77+
if err != nil {
78+
return xerrors.Errorf("could not determine current working directory: %w", err)
79+
}
80+
loc, err := tz.TimezoneIANA()
81+
if err != nil {
82+
loc = time.UTC
83+
}
84+
tsStr := time.Now().In(loc).Format("2006-01-02-150405")
85+
fname := "coder-support-" + tsStr + ".zip"
86+
outputPath = filepath.Join(cwd, fname)
87+
}
88+
89+
w, err := os.Create(outputPath)
90+
if err != nil {
91+
return xerrors.Errorf("create output file: %w", err)
92+
}
93+
zwr := zip.NewWriter(w)
94+
defer zwr.Close()
95+
if err := writeBundle(bun, zwr); err != nil {
96+
return xerrors.Errorf("write support bundle to %s: %w", outputPath, err)
97+
}
98+
return nil
99+
},
100+
}
101+
cmd.Options = clibase.OptionSet{
102+
{
103+
Flag: "output",
104+
FlagShorthand: "o",
105+
Env: "CODER_SUPPORT_BUNDLE_OUTPUT",
106+
Description: "Path to which to output the generated support bundle. Defaults to coder-support-YYYYmmdd-HHMMSS.zip.",
107+
Value: clibase.StringOf(&outputPath),
108+
},
109+
}
110+
111+
return cmd
112+
}
113+
114+
func findAgent(agentName string, haystack []codersdk.WorkspaceResource) (*codersdk.WorkspaceAgent, bool) {
115+
for _, res := range haystack {
116+
for _, agt := range res.Agents {
117+
if agentName == "" {
118+
// just return the first
119+
return &agt, true
120+
}
121+
if agt.Name == agentName {
122+
return &agt, true
123+
}
124+
}
125+
}
126+
return nil, false
127+
}
128+
129+
func writeBundle(src *support.Bundle, dest *zip.Writer) error {
130+
for k, v := range map[string]any{
131+
"deployment/buildinfo.json": src.Deployment.BuildInfo,
132+
"deployment/config.json": src.Deployment.Config,
133+
"deployment/experiments.json": src.Deployment.Experiments,
134+
"deployment/health.json": src.Deployment.HealthReport,
135+
"network/netcheck_local.json": src.Network.NetcheckLocal,
136+
"network/netcheck_remote.json": src.Network.NetcheckRemote,
137+
"workspace/workspace.json": src.Workspace.Workspace,
138+
"workspace/agent.json": src.Workspace.Agent,
139+
} {
140+
f, err := dest.Create(k)
141+
if err != nil {
142+
return xerrors.Errorf("create file %q in archive: %w", k, err)
143+
}
144+
err = json.NewEncoder(f).Encode(v)
145+
if err != nil {
146+
return xerrors.Errorf("write json to %q: %w", k, err)
147+
}
148+
}
149+
150+
for k, v := range map[string]string{
151+
"network/coordinator_debug.html": src.Network.CoordinatorDebug,
152+
"network/tailnet_debug.html": src.Network.TailnetDebug,
153+
"workspace/build_logs.txt": humanizeBuildLogs(src.Workspace.BuildLogs),
154+
"workspace/agent_startup_logs.txt": humanizeAgentLogs(src.Workspace.AgentStartupLogs),
155+
"logs.txt": strings.Join(src.Logs, "\n"),
156+
} {
157+
f, err := dest.Create(k)
158+
if err != nil {
159+
return xerrors.Errorf("create file %q in archive: %w", k, err)
160+
}
161+
if _, err := f.Write([]byte(v)); err != nil {
162+
return xerrors.Errorf("write file %q in archive: %w", k, err)
163+
}
164+
}
165+
if err := dest.Close(); err != nil {
166+
return xerrors.Errorf("close zip file: %w", err)
167+
}
168+
return nil
169+
}
170+
171+
func humanizeAgentLogs(ls []codersdk.WorkspaceAgentLog) string {
172+
var l log.Logger
173+
174+
var buf bytes.Buffer
175+
l.SetOutput(&buf)
176+
tw := tabwriter.NewWriter(&buf, 0, 2, 1, ' ', 0)
177+
for _, l := range ls {
178+
_, _ = fmt.Fprintf(tw, "%s\t[%s]\t%s\n",
179+
l.CreatedAt.Format(time.RFC3339),
180+
string(l.Level),
181+
l.Output,
182+
)
183+
}
184+
_ = tw.Flush()
185+
return buf.String()
186+
}
187+
188+
func humanizeBuildLogs(ls []codersdk.ProvisionerJobLog) string {
189+
var buf bytes.Buffer
190+
tw := tabwriter.NewWriter(&buf, 0, 2, 1, ' ', 0)
191+
for _, l := range ls {
192+
_, _ = fmt.Fprintf(tw, "%s\t[%s]\t%s\t%s\t%s\n",
193+
l.CreatedAt.Format(time.RFC3339),
194+
string(l.Level),
195+
string(l.Source),
196+
l.Stage,
197+
l.Output,
198+
)
199+
}
200+
_ = tw.Flush()
201+
return buf.String()
202+
}

cli/support_test.go

Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
1+
package cli_test
2+
3+
import (
4+
"archive/zip"
5+
"encoding/json"
6+
"io"
7+
"path/filepath"
8+
"testing"
9+
"time"
10+
11+
"github.com/stretchr/testify/require"
12+
13+
"github.com/coder/coder/v2/cli/clitest"
14+
"github.com/coder/coder/v2/coderd/coderdtest"
15+
"github.com/coder/coder/v2/coderd/database"
16+
"github.com/coder/coder/v2/coderd/database/dbfake"
17+
"github.com/coder/coder/v2/coderd/database/dbtime"
18+
"github.com/coder/coder/v2/codersdk"
19+
"github.com/coder/coder/v2/testutil"
20+
)
21+
22+
func TestSupport(t *testing.T) {
23+
t.Parallel()
24+
25+
t.Run("Workspace", func(t *testing.T) {
26+
t.Parallel()
27+
ctx := testutil.Context(t, testutil.WaitShort)
28+
client, db := coderdtest.NewWithDatabase(t, nil)
29+
owner := coderdtest.CreateFirstUser(t, client)
30+
r := dbfake.WorkspaceBuild(t, db, database.Workspace{
31+
OrganizationID: owner.OrganizationID,
32+
OwnerID: owner.UserID,
33+
}).WithAgent().Do()
34+
ws, err := client.Workspace(ctx, r.Workspace.ID)
35+
require.NoError(t, err)
36+
agt := ws.LatestBuild.Resources[0].Agents[0]
37+
38+
// Insert a provisioner job log
39+
_, err = db.InsertProvisionerJobLogs(ctx, database.InsertProvisionerJobLogsParams{
40+
JobID: r.Build.JobID,
41+
CreatedAt: []time.Time{dbtime.Now()},
42+
Source: []database.LogSource{database.LogSourceProvisionerDaemon},
43+
Level: []database.LogLevel{database.LogLevelInfo},
44+
Stage: []string{"provision"},
45+
Output: []string{"done"},
46+
})
47+
require.NoError(t, err)
48+
// Insert an agent log
49+
_, err = db.InsertWorkspaceAgentLogs(ctx, database.InsertWorkspaceAgentLogsParams{
50+
AgentID: agt.ID,
51+
CreatedAt: dbtime.Now(),
52+
Output: []string{"started up"},
53+
Level: []database.LogLevel{database.LogLevelInfo},
54+
LogSourceID: r.Build.JobID,
55+
OutputLength: 10,
56+
})
57+
require.NoError(t, err)
58+
59+
d := t.TempDir()
60+
path := filepath.Join(d, "bundle.zip")
61+
inv, root := clitest.New(t, "support", r.Workspace.Name, "--output", path)
62+
//nolint: gocritic // requires owner privilege
63+
clitest.SetupConfig(t, client, root)
64+
err = inv.Run()
65+
require.NoError(t, err)
66+
assertBundleContents(t, path)
67+
})
68+
69+
t.Run("NoWorkspace", func(t *testing.T) {
70+
t.Parallel()
71+
client := coderdtest.New(t, nil)
72+
_ = coderdtest.CreateFirstUser(t, client)
73+
inv, root := clitest.New(t, "support")
74+
//nolint: gocritic // requires owner privilege
75+
clitest.SetupConfig(t, client, root)
76+
err := inv.Run()
77+
require.ErrorContains(t, err, "must specify workspace name")
78+
})
79+
80+
t.Run("NoAgent", func(t *testing.T) {
81+
t.Parallel()
82+
client, db := coderdtest.NewWithDatabase(t, nil)
83+
admin := coderdtest.CreateFirstUser(t, client)
84+
r := dbfake.WorkspaceBuild(t, db, database.Workspace{
85+
OrganizationID: admin.OrganizationID,
86+
OwnerID: admin.UserID,
87+
}).Do() // without agent!
88+
inv, root := clitest.New(t, "support", r.Workspace.Name)
89+
//nolint: gocritic // requires owner privilege
90+
clitest.SetupConfig(t, client, root)
91+
err := inv.Run()
92+
require.ErrorContains(t, err, "could not find agent")
93+
})
94+
95+
t.Run("NoPrivilege", func(t *testing.T) {
96+
t.Parallel()
97+
client, db := coderdtest.NewWithDatabase(t, nil)
98+
user := coderdtest.CreateFirstUser(t, client)
99+
memberClient, member := coderdtest.CreateAnotherUser(t, client, user.OrganizationID)
100+
r := dbfake.WorkspaceBuild(t, db, database.Workspace{
101+
OrganizationID: user.OrganizationID,
102+
OwnerID: member.ID,
103+
}).WithAgent().Do()
104+
inv, root := clitest.New(t, "support", r.Workspace.Name)
105+
clitest.SetupConfig(t, memberClient, root)
106+
err := inv.Run()
107+
require.ErrorContains(t, err, "failed authorization check")
108+
})
109+
}
110+
111+
func assertBundleContents(t *testing.T, path string) {
112+
t.Helper()
113+
r, err := zip.OpenReader(path)
114+
require.NoError(t, err, "open zip file")
115+
defer r.Close()
116+
for _, f := range r.File {
117+
switch f.Name {
118+
case "deployment/buildinfo.json":
119+
var v codersdk.BuildInfoResponse
120+
decodeJSONFromZip(t, f, &v)
121+
require.NotEmpty(t, v, "deployment build info should not be empty")
122+
case "deployment/config.json":
123+
var v codersdk.DeploymentConfig
124+
decodeJSONFromZip(t, f, &v)
125+
require.NotEmpty(t, v, "deployment config should not be empty")
126+
case "deployment/experiments.json":
127+
var v codersdk.Experiments
128+
decodeJSONFromZip(t, f, &v)
129+
require.NotEmpty(t, f, v, "experiments should not be empty")
130+
case "deployment/health.json":
131+
var v codersdk.HealthcheckReport
132+
decodeJSONFromZip(t, f, &v)
133+
require.NotEmpty(t, v, "health report should not be empty")
134+
case "network/coordinator_debug.html":
135+
bs := readBytesFromZip(t, f)
136+
require.NotEmpty(t, bs, "coordinator debug should not be empty")
137+
case "network/tailnet_debug.html":
138+
bs := readBytesFromZip(t, f)
139+
require.NotEmpty(t, bs, "tailnet debug should not be empty")
140+
case "network/netcheck_local.json", "network/netcheck_remote.json":
141+
// TODO: setup fake agent?
142+
bs := readBytesFromZip(t, f)
143+
require.NotEmpty(t, bs, "netcheck should not be empty")
144+
case "workspace/workspace.json":
145+
var v codersdk.Workspace
146+
decodeJSONFromZip(t, f, &v)
147+
require.NotEmpty(t, v, "workspace should not be empty")
148+
case "workspace/build_logs.txt":
149+
bs := readBytesFromZip(t, f)
150+
require.Contains(t, string(bs), "provision done")
151+
case "workspace/agent.json":
152+
var v codersdk.WorkspaceAgent
153+
decodeJSONFromZip(t, f, &v)
154+
require.NotEmpty(t, v, "agent should not be empty")
155+
case "workspace/agent_startup_logs.txt":
156+
bs := readBytesFromZip(t, f)
157+
require.Contains(t, string(bs), "started up")
158+
case "logs.txt":
159+
bs := readBytesFromZip(t, f)
160+
require.NotEmpty(t, bs, "logs should not be empty")
161+
default:
162+
require.Fail(t, "unexpected file in bundle", f.Name)
163+
}
164+
}
165+
}
166+
167+
func decodeJSONFromZip(t *testing.T, f *zip.File, dest any) {
168+
t.Helper()
169+
rc, err := f.Open()
170+
require.NoError(t, err, "open file from zip")
171+
defer rc.Close()
172+
require.NoError(t, json.NewDecoder(rc).Decode(&dest))
173+
}
174+
175+
func readBytesFromZip(t *testing.T, f *zip.File) []byte {
176+
t.Helper()
177+
rc, err := f.Open()
178+
require.NoError(t, err, "open file from zip")
179+
bs, err := io.ReadAll(rc)
180+
require.NoError(t, err, "read bytes from zip")
181+
return bs
182+
}

0 commit comments

Comments
 (0)