mirror of
https://github.com/coder/coder.git
synced 2025-07-06 15:41:45 +00:00
feat(cli): add support cmd (#12328)
Part of #12163 - Adds a command coder support bundle <workspace> 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.
This commit is contained in:
@ -123,6 +123,7 @@ func (r *RootCmd) Core() []*clibase.Cmd {
|
|||||||
r.vscodeSSH(),
|
r.vscodeSSH(),
|
||||||
r.workspaceAgent(),
|
r.workspaceAgent(),
|
||||||
r.expCmd(),
|
r.expCmd(),
|
||||||
|
r.support(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
209
cli/support.go
Normal file
209
cli/support.go
Normal file
@ -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)
|
||||||
|
}
|
||||||
|
|
||||||
|
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()
|
||||||
|
}
|
183
cli/support_test.go
Normal file
183
cli/support_test.go
Normal file
@ -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
|
||||||
|
}
|
@ -155,7 +155,13 @@ func WorkspaceInfo(ctx context.Context, client *codersdk.Client, log slog.Logger
|
|||||||
return w
|
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.Workspace = ws
|
||||||
|
w.Agent = agt
|
||||||
|
|
||||||
buildLogCh, closer, err := client.WorkspaceBuildLogsAfter(ctx, ws.LatestBuild.ID, 0)
|
buildLogCh, closer, err := client.WorkspaceBuildLogsAfter(ctx, ws.LatestBuild.ID, 0)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -203,7 +209,7 @@ func Run(ctx context.Context, d *Deps) (*Bundle, error) {
|
|||||||
|
|
||||||
// Ensure we capture logs from the client.
|
// Ensure we capture logs from the client.
|
||||||
var logw strings.Builder
|
var logw strings.Builder
|
||||||
d.Log.AppendSinks(sloghuman.Sink(&logw))
|
d.Log = d.Log.AppendSinks(sloghuman.Sink(&logw))
|
||||||
d.Client.SetLogger(d.Log)
|
d.Client.SetLogger(d.Log)
|
||||||
defer func() {
|
defer func() {
|
||||||
b.Logs = strings.Split(logw.String(), "\n")
|
b.Logs = strings.Split(logw.String(), "\n")
|
||||||
|
Reference in New Issue
Block a user