Skip to content

Commit ab0c40f

Browse files
committed
feat(cli): add support command and accompanying unit tests
1 parent e5d9114 commit ab0c40f

File tree

5 files changed

+409
-1
lines changed

5 files changed

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

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)