feat(cli/support): confirm before creating bundle (#12684)

Forces user to confirm before creating a support bundle.
Also adds contextual information to the bundle under cli_logs.txt.
This commit is contained in:
Cian Johnston
2024-03-21 17:06:28 +00:00
committed by GitHub
parent 8ea5fb7115
commit f2a9e515df
3 changed files with 107 additions and 18 deletions
+99 -14
View File
@@ -6,6 +6,7 @@ import (
"encoding/base64" "encoding/base64"
"encoding/json" "encoding/json"
"fmt" "fmt"
"net/url"
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
@@ -16,6 +17,7 @@ import (
"cdr.dev/slog" "cdr.dev/slog"
"cdr.dev/slog/sloggers/sloghuman" "cdr.dev/slog/sloggers/sloghuman"
"github.com/coder/coder/v2/cli/cliui"
"github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/support" "github.com/coder/coder/v2/support"
"github.com/coder/serpent" "github.com/coder/serpent"
@@ -36,8 +38,26 @@ func (r *RootCmd) support() *serpent.Command {
return supportCmd return supportCmd
} }
var supportBundleBlurb = cliui.Bold("This will collect the following information:\n") +
` - Coder deployment version
- Coder deployment Configuration (sanitized), including enabled experiments
- Coder deployment health snapshot
- Coder deployment Network troubleshooting information
- Workspace configuration, parameters, and build logs
- Template version and source code for the given workspace
- Agent details (with environment variable sanitized)
- Agent network diagnostics
- Agent logs
` + cliui.Bold("Note: ") +
cliui.Wrap(`While we try to sanitize sensitive data from support bundles, we cannot guarantee that they do not contain information that you or your organization may consider sensitive.\n`) +
cliui.Bold("Please confirm that you will:\n") +
" - Review the support bundle before distribution\n" +
" - Only distribute it via trusted channels\n" +
cliui.Bold("Continue? ")
func (r *RootCmd) supportBundle() *serpent.Command { func (r *RootCmd) supportBundle() *serpent.Command {
var outputPath string var outputPath string
var coderURLOverride string
client := new(codersdk.Client) client := new(codersdk.Client)
cmd := &serpent.Command{ cmd := &serpent.Command{
Use: "bundle <workspace> [<agent>]", Use: "bundle <workspace> [<agent>]",
@@ -48,14 +68,52 @@ func (r *RootCmd) supportBundle() *serpent.Command {
r.InitClient(client), r.InitClient(client),
), ),
Handler: func(inv *serpent.Invocation) error { Handler: func(inv *serpent.Invocation) error {
var ( var cliLogBuf bytes.Buffer
log = slog.Make(sloghuman.Sink(inv.Stderr)). cliLogW := sloghuman.Sink(&cliLogBuf)
Leveled(slog.LevelDebug) cliLog := slog.Make(cliLogW).Leveled(slog.LevelDebug)
deps = support.Deps{ if r.verbose {
Client: client, cliLog = cliLog.AppendSinks(sloghuman.Sink(inv.Stderr))
Log: log, }
} ans, err := cliui.Prompt(inv, cliui.PromptOptions{
Text: supportBundleBlurb,
Secret: false,
IsConfirm: true,
})
if err != nil || ans != cliui.ConfirmYes {
return err
}
if skip, _ := inv.ParsedFlags().GetBool("yes"); skip {
cliLog.Debug(inv.Context(), "user auto-confirmed")
} else {
cliLog.Debug(inv.Context(), "user confirmed manually", slog.F("answer", ans))
}
vi := defaultVersionInfo()
cliLog.Debug(inv.Context(), "version info",
slog.F("version", vi.Version),
slog.F("build_time", vi.BuildTime),
slog.F("external_url", vi.ExternalURL),
slog.F("slim", vi.Slim),
slog.F("agpl", vi.AGPL),
slog.F("boring_crypto", vi.BoringCrypto),
) )
cliLog.Debug(inv.Context(), "invocation", slog.F("args", strings.Join(os.Args, " ")))
// Check if we're running inside a workspace
if val, found := os.LookupEnv("CODER"); found && val == "true" {
_, _ = fmt.Fprintln(inv.Stderr, "Running inside Coder workspace; this can affect results!")
cliLog.Debug(inv.Context(), "running inside coder workspace")
}
if coderURLOverride != "" && coderURLOverride != client.URL.String() {
u, err := url.Parse(coderURLOverride)
if err != nil {
return xerrors.Errorf("invalid value for Coder URL override: %w", err)
}
_, _ = fmt.Fprintf(inv.Stderr, "Overrode Coder URL to %q; this can affect results!\n", coderURLOverride)
cliLog.Debug(inv.Context(), "coder url overridden", slog.F("url", coderURLOverride))
client.URL = u
}
if len(inv.Args) == 0 { if len(inv.Args) == 0 {
return xerrors.Errorf("must specify workspace name") return xerrors.Errorf("must specify workspace name")
@@ -64,8 +122,10 @@ func (r *RootCmd) supportBundle() *serpent.Command {
if err != nil { if err != nil {
return xerrors.Errorf("invalid workspace: %w", err) return xerrors.Errorf("invalid workspace: %w", err)
} }
cliLog.Debug(inv.Context(), "found workspace",
deps.WorkspaceID = ws.ID slog.F("workspace_name", ws.Name),
slog.F("workspace_id", ws.ID),
)
agentName := "" agentName := ""
if len(inv.Args) > 1 { if len(inv.Args) > 1 {
@@ -76,8 +136,10 @@ func (r *RootCmd) supportBundle() *serpent.Command {
if !found { if !found {
return xerrors.Errorf("could not find agent named %q for workspace", agentName) return xerrors.Errorf("could not find agent named %q for workspace", agentName)
} }
cliLog.Debug(inv.Context(), "found workspace agent",
deps.AgentID = agt.ID slog.F("agent_name", agt.Name),
slog.F("agent_id", agt.ID),
)
if outputPath == "" { if outputPath == "" {
cwd, err := filepath.Abs(".") cwd, err := filepath.Abs(".")
@@ -87,6 +149,7 @@ func (r *RootCmd) supportBundle() *serpent.Command {
fname := fmt.Sprintf("coder-support-%d.zip", time.Now().Unix()) fname := fmt.Sprintf("coder-support-%d.zip", time.Now().Unix())
outputPath = filepath.Join(cwd, fname) outputPath = filepath.Join(cwd, fname)
} }
cliLog.Debug(inv.Context(), "output path", slog.F("path", outputPath))
w, err := os.Create(outputPath) w, err := os.Create(outputPath)
if err != nil { if err != nil {
@@ -95,27 +158,48 @@ func (r *RootCmd) supportBundle() *serpent.Command {
zwr := zip.NewWriter(w) zwr := zip.NewWriter(w)
defer zwr.Close() defer zwr.Close()
clientLog := slog.Make().Leveled(slog.LevelDebug)
if r.verbose {
clientLog.AppendSinks(sloghuman.Sink(inv.Stderr))
}
deps := support.Deps{
Client: client,
// Support adds a sink so we don't need to supply one ourselves.
Log: clientLog,
WorkspaceID: ws.ID,
AgentID: agt.ID,
}
bun, err := support.Run(inv.Context(), &deps) bun, err := support.Run(inv.Context(), &deps)
if err != nil { if err != nil {
_ = os.Remove(outputPath) // best effort _ = os.Remove(outputPath) // best effort
return xerrors.Errorf("create support bundle: %w", err) return xerrors.Errorf("create support bundle: %w", err)
} }
bun.CLILogs = cliLogBuf.Bytes()
if err := writeBundle(bun, zwr); err != nil { if err := writeBundle(bun, zwr); err != nil {
_ = os.Remove(outputPath) // best effort _ = os.Remove(outputPath) // best effort
return xerrors.Errorf("write support bundle to %s: %w", outputPath, err) return xerrors.Errorf("write support bundle to %s: %w", outputPath, err)
} }
_, _ = fmt.Fprintln(inv.Stderr, "Wrote support bundle to "+outputPath)
return nil return nil
}, },
} }
cmd.Options = serpent.OptionSet{ cmd.Options = serpent.OptionSet{
cliui.SkipPromptOption(),
{ {
Flag: "output", Flag: "output-file",
FlagShorthand: "o", FlagShorthand: "O",
Env: "CODER_SUPPORT_BUNDLE_OUTPUT", Env: "CODER_SUPPORT_BUNDLE_OUTPUT_FILE",
Description: "File path for writing the generated support bundle. Defaults to coder-support-$(date +%s).zip.", Description: "File path for writing the generated support bundle. Defaults to coder-support-$(date +%s).zip.",
Value: serpent.StringOf(&outputPath), Value: serpent.StringOf(&outputPath),
}, },
{
Flag: "url-override",
Env: "CODER_SUPPORT_BUNDLE_URL_OVERRIDE",
Description: "Override the URL to your Coder deployment. This may be useful, for example, if you need to troubleshoot a specific Coder replica.",
Value: serpent.StringOf(&coderURLOverride),
},
} }
return cmd return cmd
@@ -182,6 +266,7 @@ func writeBundle(src *support.Bundle, dest *zip.Writer) error {
"agent/prometheus.txt": string(src.Agent.Prometheus), "agent/prometheus.txt": string(src.Agent.Prometheus),
"workspace/template_file.zip": string(templateVersionBytes), "workspace/template_file.zip": string(templateVersionBytes),
"logs.txt": strings.Join(src.Logs, "\n"), "logs.txt": strings.Join(src.Logs, "\n"),
"cli_logs.txt": string(src.CLILogs),
} { } {
f, err := dest.Create(k) f, err := dest.Create(k)
if err != nil { if err != nil {
+7 -4
View File
@@ -76,7 +76,7 @@ func TestSupportBundle(t *testing.T) {
d := t.TempDir() d := t.TempDir()
path := filepath.Join(d, "bundle.zip") path := filepath.Join(d, "bundle.zip")
inv, root := clitest.New(t, "support", "bundle", r.Workspace.Name, "--output", path) inv, root := clitest.New(t, "support", "bundle", r.Workspace.Name, "--output-file", path, "--yes")
//nolint: gocritic // requires owner privilege //nolint: gocritic // requires owner privilege
clitest.SetupConfig(t, client, root) clitest.SetupConfig(t, client, root)
err = inv.Run() err = inv.Run()
@@ -88,7 +88,7 @@ func TestSupportBundle(t *testing.T) {
t.Parallel() t.Parallel()
client := coderdtest.New(t, nil) client := coderdtest.New(t, nil)
_ = coderdtest.CreateFirstUser(t, client) _ = coderdtest.CreateFirstUser(t, client)
inv, root := clitest.New(t, "support", "bundle") inv, root := clitest.New(t, "support", "bundle", "--yes")
//nolint: gocritic // requires owner privilege //nolint: gocritic // requires owner privilege
clitest.SetupConfig(t, client, root) clitest.SetupConfig(t, client, root)
err := inv.Run() err := inv.Run()
@@ -103,7 +103,7 @@ func TestSupportBundle(t *testing.T) {
OrganizationID: admin.OrganizationID, OrganizationID: admin.OrganizationID,
OwnerID: admin.UserID, OwnerID: admin.UserID,
}).Do() // without agent! }).Do() // without agent!
inv, root := clitest.New(t, "support", "bundle", r.Workspace.Name) inv, root := clitest.New(t, "support", "bundle", r.Workspace.Name, "--yes")
//nolint: gocritic // requires owner privilege //nolint: gocritic // requires owner privilege
clitest.SetupConfig(t, client, root) clitest.SetupConfig(t, client, root)
err := inv.Run() err := inv.Run()
@@ -119,7 +119,7 @@ func TestSupportBundle(t *testing.T) {
OrganizationID: user.OrganizationID, OrganizationID: user.OrganizationID,
OwnerID: member.ID, OwnerID: member.ID,
}).WithAgent().Do() }).WithAgent().Do()
inv, root := clitest.New(t, "support", "bundle", r.Workspace.Name) inv, root := clitest.New(t, "support", "bundle", r.Workspace.Name, "--yes")
clitest.SetupConfig(t, memberClient, root) clitest.SetupConfig(t, memberClient, root)
err := inv.Run() err := inv.Run()
require.ErrorContains(t, err, "failed authorization check") require.ErrorContains(t, err, "failed authorization check")
@@ -219,6 +219,9 @@ func assertBundleContents(t *testing.T, path string) {
case "logs.txt": case "logs.txt":
bs := readBytesFromZip(t, f) bs := readBytesFromZip(t, f)
require.NotEmpty(t, bs, "logs should not be empty") require.NotEmpty(t, bs, "logs should not be empty")
case "cli_logs.txt":
bs := readBytesFromZip(t, f)
require.NotEmpty(t, bs, "CLI logs should not be empty")
default: default:
require.Failf(t, "unexpected file in bundle", f.Name) require.Failf(t, "unexpected file in bundle", f.Name)
} }
+1
View File
@@ -33,6 +33,7 @@ type Bundle struct {
Workspace Workspace `json:"workspace"` Workspace Workspace `json:"workspace"`
Agent Agent `json:"agent"` Agent Agent `json:"agent"`
Logs []string `json:"logs"` Logs []string `json:"logs"`
CLILogs []byte `json:"cli_logs"`
} }
type Deployment struct { type Deployment struct {