coder/cli/root.go

1259 lines
37 KiB
Go

package cli
import (
"bufio"
"bytes"
"context"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"os"
"os/exec"
"os/signal"
"path/filepath"
"runtime"
"runtime/trace"
"strings"
"sync"
"syscall"
"text/tabwriter"
"time"
"github.com/mattn/go-isatty"
"github.com/mitchellh/go-wordwrap"
"golang.org/x/exp/slices"
"golang.org/x/mod/semver"
"golang.org/x/xerrors"
"github.com/coder/pretty"
"github.com/coder/coder/v2/buildinfo"
"github.com/coder/coder/v2/cli/cliui"
"github.com/coder/coder/v2/cli/config"
"github.com/coder/coder/v2/cli/gitauth"
"github.com/coder/coder/v2/cli/telemetry"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/codersdk/agentsdk"
"github.com/coder/serpent"
)
var (
Caret = pretty.Sprint(cliui.DefaultStyles.Prompt, "")
// Applied as annotations to workspace commands
// so they display in a separated "help" section.
workspaceCommand = map[string]string{
"workspaces": "",
}
)
const (
varURL = "url"
varToken = "token"
varAgentToken = "agent-token"
varAgentTokenFile = "agent-token-file"
varAgentURL = "agent-url"
varHeader = "header"
varHeaderCommand = "header-command"
varNoOpen = "no-open"
varNoVersionCheck = "no-version-warning"
varNoFeatureWarning = "no-feature-warning"
varForceTty = "force-tty"
varVerbose = "verbose"
varOrganizationSelect = "organization"
varDisableDirect = "disable-direct-connections"
notLoggedInMessage = "You are not logged in. Try logging in using 'coder login <url>'."
envNoVersionCheck = "CODER_NO_VERSION_WARNING"
envNoFeatureWarning = "CODER_NO_FEATURE_WARNING"
envSessionToken = "CODER_SESSION_TOKEN"
//nolint:gosec
envAgentToken = "CODER_AGENT_TOKEN"
//nolint:gosec
envAgentTokenFile = "CODER_AGENT_TOKEN_FILE"
envURL = "CODER_URL"
)
func (r *RootCmd) CoreSubcommands() []*serpent.Command {
// Please re-sort this list alphabetically if you change it!
return []*serpent.Command{
r.dotfiles(),
r.externalAuth(),
r.login(),
r.logout(),
r.netcheck(),
r.portForward(),
r.publickey(),
r.resetPassword(),
r.state(),
r.templates(),
r.tokens(),
r.users(),
r.version(defaultVersionInfo),
r.organizations(),
// Workspace Commands
r.autoupdate(),
r.configSSH(),
r.create(),
r.deleteWorkspace(),
r.favorite(),
r.list(),
r.open(),
r.ping(),
r.rename(),
r.restart(),
r.schedules(),
r.show(),
r.speedtest(),
r.ssh(),
r.start(),
r.stat(),
r.stop(),
r.unfavorite(),
r.update(),
// Hidden
r.gitssh(),
r.vscodeSSH(),
r.workspaceAgent(),
r.expCmd(),
r.support(),
}
}
func (r *RootCmd) AGPL() []*serpent.Command {
all := append(r.CoreSubcommands(), r.Server( /* Do not import coderd here. */ nil))
return all
}
// RunWithSubcommands runs the root command with the given subcommands.
// It is abstracted to enable the Enterprise code to add commands.
func (r *RootCmd) RunWithSubcommands(subcommands []*serpent.Command) {
// This configuration is not available as a standard option because we
// want to trace the entire program, including Options parsing.
goTraceFilePath, ok := os.LookupEnv("CODER_GO_TRACE")
if ok {
traceFile, err := os.OpenFile(goTraceFilePath, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o644)
if err != nil {
panic(fmt.Sprintf("failed to open trace file: %v", err))
}
defer traceFile.Close()
if err := trace.Start(traceFile); err != nil {
panic(fmt.Sprintf("failed to start trace: %v", err))
}
defer trace.Stop()
}
cmd, err := r.Command(subcommands)
if err != nil {
panic(err)
}
err = cmd.Invoke().WithOS().Run()
if err != nil {
code := 1
var exitErr *exitError
if errors.As(err, &exitErr) {
code = exitErr.code
err = exitErr.err
}
if errors.Is(err, cliui.Canceled) {
//nolint:revive
os.Exit(code)
}
f := PrettyErrorFormatter{w: os.Stderr, verbose: r.verbose}
if err != nil {
f.Format(err)
}
//nolint:revive
os.Exit(code)
}
}
func (r *RootCmd) Command(subcommands []*serpent.Command) (*serpent.Command, error) {
fmtLong := `Coder %s — A tool for provisioning self-hosted development environments with Terraform.
`
cmd := &serpent.Command{
Use: "coder [global-flags] <subcommand>",
Long: fmt.Sprintf(fmtLong, buildinfo.Version()) + formatExamples(
example{
Description: "Start a Coder server",
Command: "coder server",
},
example{
Description: "Get started by creating a template from an example",
Command: "coder templates init",
},
),
Handler: func(i *serpent.Invocation) error {
if r.versionFlag {
return r.version(defaultVersionInfo).Handler(i)
}
// The GIT_ASKPASS environment variable must point at
// a binary with no arguments. To prevent writing
// cross-platform scripts to invoke the Coder binary
// with a `gitaskpass` subcommand, we override the entrypoint
// to check if the command was invoked.
if gitauth.CheckCommand(i.Args, i.Environ.ToOS()) {
return r.gitAskpass().Handler(i)
}
return i.Command.HelpHandler(i)
},
}
cmd.AddSubcommands(subcommands...)
// Set default help handler for all commands.
cmd.Walk(func(c *serpent.Command) {
if c.HelpHandler == nil {
c.HelpHandler = helpFn()
}
})
var merr error
// Add [flags] to usage when appropriate.
cmd.Walk(func(cmd *serpent.Command) {
const flags = "[flags]"
if strings.Contains(cmd.Use, flags) {
merr = errors.Join(
merr,
xerrors.Errorf(
"command %q shouldn't have %q in usage since it's automatically populated",
cmd.FullUsage(),
flags,
),
)
return
}
var hasFlag bool
for _, opt := range cmd.Options {
if opt.Flag != "" {
hasFlag = true
break
}
}
if !hasFlag {
return
}
// We insert [flags] between the command's name and its arguments.
tokens := strings.SplitN(cmd.Use, " ", 2)
if len(tokens) == 1 {
cmd.Use = fmt.Sprintf("%s %s", tokens[0], flags)
return
}
cmd.Use = fmt.Sprintf("%s %s %s", tokens[0], flags, tokens[1])
})
// Add alises when appropriate.
cmd.Walk(func(cmd *serpent.Command) {
// TODO: we should really be consistent about naming.
if cmd.Name() == "delete" || cmd.Name() == "remove" {
if slices.Contains(cmd.Aliases, "rm") {
merr = errors.Join(
merr,
xerrors.Errorf("command %q shouldn't have alias %q since it's added automatically", cmd.FullName(), "rm"),
)
return
}
cmd.Aliases = append(cmd.Aliases, "rm")
}
})
// Sanity-check command options.
cmd.Walk(func(cmd *serpent.Command) {
for _, opt := range cmd.Options {
// Verify that every option is configurable.
if opt.Flag == "" && opt.Env == "" {
if cmd.Name() == "server" {
// The server command is funky and has YAML-only options, e.g.
// support links.
return
}
merr = errors.Join(
merr,
xerrors.Errorf("option %q in %q should have a flag or env", opt.Name, cmd.FullName()),
)
}
}
})
if merr != nil {
return nil, merr
}
var debugOptions bool
// Add a wrapper to every command to enable debugging options.
cmd.Walk(func(cmd *serpent.Command) {
h := cmd.Handler
if h == nil {
// We should never have a nil handler, but if we do, do not
// wrap it. Wrapping it just hides a nil pointer dereference.
// If a nil handler exists, this is a developer bug. If no handler
// is required for a command such as command grouping (e.g. `users'
// and 'groups'), then the handler should be set to the helper
// function.
// func(inv *serpent.Invocation) error {
// return inv.Command.HelpHandler(inv)
// }
return
}
cmd.Handler = func(i *serpent.Invocation) error {
if !debugOptions {
return h(i)
}
tw := tabwriter.NewWriter(i.Stdout, 0, 0, 4, ' ', 0)
_, _ = fmt.Fprintf(tw, "Option\tValue Source\n")
for _, opt := range cmd.Options {
_, _ = fmt.Fprintf(tw, "%q\t%v\n", opt.Name, opt.ValueSource)
}
tw.Flush()
return nil
}
})
if r.agentURL == nil {
r.agentURL = new(url.URL)
}
if r.clientURL == nil {
r.clientURL = new(url.URL)
}
globalGroup := &serpent.Group{
Name: "Global",
Description: `Global options are applied to all commands. They can be set using environment variables or flags.`,
}
cmd.Options = serpent.OptionSet{
{
Flag: varURL,
Env: envURL,
Description: "URL to a deployment.",
Value: serpent.URLOf(r.clientURL),
Group: globalGroup,
},
{
Flag: "debug-options",
Description: "Print all options, how they're set, then exit.",
Value: serpent.BoolOf(&debugOptions),
Group: globalGroup,
},
{
Flag: varToken,
Env: envSessionToken,
Description: fmt.Sprintf("Specify an authentication token. For security reasons setting %s is preferred.", envSessionToken),
Value: serpent.StringOf(&r.token),
Group: globalGroup,
},
{
Flag: varAgentToken,
Env: envAgentToken,
Description: "An agent authentication token.",
Value: serpent.StringOf(&r.agentToken),
Hidden: true,
Group: globalGroup,
},
{
Flag: varAgentTokenFile,
Env: envAgentTokenFile,
Description: "A file containing an agent authentication token.",
Value: serpent.StringOf(&r.agentTokenFile),
Hidden: true,
Group: globalGroup,
},
{
Flag: varAgentURL,
Env: "CODER_AGENT_URL",
Description: "URL for an agent to access your deployment.",
Value: serpent.URLOf(r.agentURL),
Hidden: true,
Group: globalGroup,
},
{
Flag: varNoVersionCheck,
Env: envNoVersionCheck,
Description: "Suppress warning when client and server versions do not match.",
Value: serpent.BoolOf(&r.noVersionCheck),
Group: globalGroup,
},
{
Flag: varNoFeatureWarning,
Env: envNoFeatureWarning,
Description: "Suppress warnings about unlicensed features.",
Value: serpent.BoolOf(&r.noFeatureWarning),
Group: globalGroup,
},
{
Flag: varHeader,
Env: "CODER_HEADER",
Description: "Additional HTTP headers added to all requests. Provide as " + `key=value` + ". Can be specified multiple times.",
Value: serpent.StringArrayOf(&r.header),
Group: globalGroup,
},
{
Flag: varHeaderCommand,
Env: "CODER_HEADER_COMMAND",
Description: "An external command that outputs additional HTTP headers added to all requests. The command must output each header as `key=value` on its own line.",
Value: serpent.StringOf(&r.headerCommand),
Group: globalGroup,
},
{
Flag: varNoOpen,
Env: "CODER_NO_OPEN",
Description: "Suppress opening the browser after logging in.",
Value: serpent.BoolOf(&r.noOpen),
Hidden: true,
Group: globalGroup,
},
{
Flag: varForceTty,
Env: "CODER_FORCE_TTY",
Hidden: true,
Description: "Force the use of a TTY.",
Value: serpent.BoolOf(&r.forceTTY),
Group: globalGroup,
},
{
Flag: varVerbose,
FlagShorthand: "v",
Env: "CODER_VERBOSE",
Description: "Enable verbose output.",
Value: serpent.BoolOf(&r.verbose),
Group: globalGroup,
},
{
Flag: varDisableDirect,
Env: "CODER_DISABLE_DIRECT_CONNECTIONS",
Description: "Disable direct (P2P) connections to workspaces.",
Value: serpent.BoolOf(&r.disableDirect),
Group: globalGroup,
},
{
Flag: "debug-http",
Description: "Debug codersdk HTTP requests.",
Value: serpent.BoolOf(&r.debugHTTP),
Group: globalGroup,
Hidden: true,
},
{
Flag: config.FlagName,
Env: "CODER_CONFIG_DIR",
Description: "Path to the global `coder` config directory.",
Default: config.DefaultDir(),
Value: serpent.StringOf(&r.globalConfig),
Group: globalGroup,
},
{
Flag: varOrganizationSelect,
FlagShorthand: "z",
Env: "CODER_ORGANIZATION",
Description: "Select which organization (uuid or name) to use This overrides what is present in the config file.",
Value: serpent.StringOf(&r.organizationSelect),
Hidden: true,
Group: globalGroup,
},
{
Flag: "version",
// This was requested by a customer to assist with their migration.
// They have two Coder CLIs, and want to tell the difference by running
// the same base command.
Description: "Run the version command. Useful for v1 customers migrating to v2.",
Value: serpent.BoolOf(&r.versionFlag),
Hidden: true,
},
}
return cmd, nil
}
// RootCmd contains parameters and helpers useful to all commands.
type RootCmd struct {
clientURL *url.URL
token string
globalConfig string
header []string
headerCommand string
agentToken string
agentTokenFile string
agentURL *url.URL
forceTTY bool
noOpen bool
verbose bool
organizationSelect string
versionFlag bool
disableDirect bool
debugHTTP bool
noVersionCheck bool
noFeatureWarning bool
}
// InitClient authenticates the client with files from disk
// and injects header middlewares for telemetry, authentication,
// and version checks.
func (r *RootCmd) InitClient(client *codersdk.Client) serpent.MiddlewareFunc {
return func(next serpent.HandlerFunc) serpent.HandlerFunc {
return func(inv *serpent.Invocation) error {
conf := r.createConfig()
var err error
// Read the client URL stored on disk.
if r.clientURL == nil || r.clientURL.String() == "" {
rawURL, err := conf.URL().Read()
// If the configuration files are absent, the user is logged out
if os.IsNotExist(err) {
return xerrors.New(notLoggedInMessage)
}
if err != nil {
return err
}
r.clientURL, err = url.Parse(strings.TrimSpace(rawURL))
if err != nil {
return err
}
}
// Read the token stored on disk.
if r.token == "" {
r.token, err = conf.Session().Read()
// Even if there isn't a token, we don't care.
// Some API routes can be unauthenticated.
if err != nil && !os.IsNotExist(err) {
return err
}
}
err = r.configureClient(inv.Context(), client, r.clientURL, inv)
if err != nil {
return err
}
client.SetSessionToken(r.token)
if r.debugHTTP {
client.PlainLogger = os.Stderr
client.SetLogBodies(true)
}
client.DisableDirectConnections = r.disableDirect
return next(inv)
}
}
}
// HeaderTransport creates a new transport that executes `--header-command`
// if it is set to add headers for all outbound requests.
func (r *RootCmd) HeaderTransport(ctx context.Context, serverURL *url.URL) (*codersdk.HeaderTransport, error) {
transport := &codersdk.HeaderTransport{
Transport: http.DefaultTransport,
Header: http.Header{},
}
headers := r.header
if r.headerCommand != "" {
shell := "sh"
caller := "-c"
if runtime.GOOS == "windows" {
shell = "cmd.exe"
caller = "/c"
}
var outBuf bytes.Buffer
// #nosec
cmd := exec.CommandContext(ctx, shell, caller, r.headerCommand)
cmd.Env = append(os.Environ(), "CODER_URL="+serverURL.String())
cmd.Stdout = &outBuf
cmd.Stderr = io.Discard
err := cmd.Run()
if err != nil {
return nil, xerrors.Errorf("failed to run %v: %w", cmd.Args, err)
}
scanner := bufio.NewScanner(&outBuf)
for scanner.Scan() {
headers = append(headers, scanner.Text())
}
if err := scanner.Err(); err != nil {
return nil, xerrors.Errorf("scan %v: %w", cmd.Args, err)
}
}
for _, header := range headers {
parts := strings.SplitN(header, "=", 2)
if len(parts) < 2 {
return nil, xerrors.Errorf("split header %q had less than two parts", header)
}
transport.Header.Add(parts[0], parts[1])
}
return transport, nil
}
func (r *RootCmd) configureClient(ctx context.Context, client *codersdk.Client, serverURL *url.URL, inv *serpent.Invocation) error {
transport := http.DefaultTransport
transport = wrapTransportWithTelemetryHeader(transport, inv)
if !r.noVersionCheck {
transport = wrapTransportWithVersionMismatchCheck(transport, inv, buildinfo.Version(), func(ctx context.Context) (codersdk.BuildInfoResponse, error) {
// Create a new client without any wrapped transport
// otherwise it creates an infinite loop!
basicClient := codersdk.New(serverURL)
return basicClient.BuildInfo(ctx)
})
}
if !r.noFeatureWarning {
transport = wrapTransportWithEntitlementsCheck(transport, inv.Stderr)
}
headerTransport, err := r.HeaderTransport(ctx, serverURL)
if err != nil {
return xerrors.Errorf("create header transport: %w", err)
}
// The header transport has to come last.
// codersdk checks for the header transport to get headers
// to clone on the DERP client.
headerTransport.Transport = transport
client.HTTPClient = &http.Client{
Transport: headerTransport,
}
client.URL = serverURL
return nil
}
func (r *RootCmd) createUnauthenticatedClient(ctx context.Context, serverURL *url.URL, inv *serpent.Invocation) (*codersdk.Client, error) {
var client codersdk.Client
err := r.configureClient(ctx, &client, serverURL, inv)
return &client, err
}
// createAgentClient returns a new client from the command context.
// It works just like CreateClient, but uses the agent token and URL instead.
func (r *RootCmd) createAgentClient() (*agentsdk.Client, error) {
client := agentsdk.New(r.agentURL)
client.SetSessionToken(r.agentToken)
return client, nil
}
// CurrentOrganization returns the currently active organization for the authenticated user.
func CurrentOrganization(r *RootCmd, inv *serpent.Invocation, client *codersdk.Client) (codersdk.Organization, error) {
conf := r.createConfig()
selected := r.organizationSelect
if selected == "" && conf.Organization().Exists() {
org, err := conf.Organization().Read()
if err != nil {
return codersdk.Organization{}, xerrors.Errorf("read selected organization from config file %q: %w", conf.Organization(), err)
}
selected = org
}
// Verify the org exists and the user is a member
orgs, err := client.OrganizationsByUser(inv.Context(), codersdk.Me)
if err != nil {
return codersdk.Organization{}, err
}
// User manually selected an organization
if selected != "" {
index := slices.IndexFunc(orgs, func(org codersdk.Organization) bool {
return org.Name == selected || org.ID.String() == selected
})
if index < 0 {
return codersdk.Organization{}, xerrors.Errorf("organization %q not found, are you sure you are a member of this organization?", selected)
}
return orgs[index], nil
}
// User did not select an organization, so use the default.
index := slices.IndexFunc(orgs, func(org codersdk.Organization) bool {
return org.IsDefault
})
if index < 0 {
if len(orgs) == 1 {
// If there is no "isDefault", but only 1 org is present. We can just
// assume the single organization is correct. This is mainly a helper
// for cli hitting an old instance, or a user that belongs to a single
// org that is not the default.
return orgs[0], nil
}
return codersdk.Organization{}, xerrors.Errorf("unable to determine current organization. Use 'coder org set <org>' to select an organization to use")
}
return orgs[index], nil
}
func splitNamedWorkspace(identifier string) (owner string, workspaceName string, err error) {
parts := strings.Split(identifier, "/")
switch len(parts) {
case 1:
owner = codersdk.Me
workspaceName = parts[0]
case 2:
owner = parts[0]
workspaceName = parts[1]
default:
return "", "", xerrors.Errorf("invalid workspace name: %q", identifier)
}
return owner, workspaceName, nil
}
// namedWorkspace fetches and returns a workspace by an identifier, which may be either
// a bare name (for a workspace owned by the current user) or a "user/workspace" combination,
// where user is either a username or UUID.
func namedWorkspace(ctx context.Context, client *codersdk.Client, identifier string) (codersdk.Workspace, error) {
owner, name, err := splitNamedWorkspace(identifier)
if err != nil {
return codersdk.Workspace{}, err
}
return client.WorkspaceByOwnerAndName(ctx, owner, name, codersdk.WorkspaceOptions{})
}
// createConfig consumes the global configuration flag to produce a config root.
func (r *RootCmd) createConfig() config.Root {
return config.Root(r.globalConfig)
}
// isTTY returns whether the passed reader is a TTY or not.
func isTTY(inv *serpent.Invocation) bool {
// If the `--force-tty` command is available, and set,
// assume we're in a tty. This is primarily for cases on Windows
// where we may not be able to reliably detect this automatically (ie, tests)
forceTty, err := inv.ParsedFlags().GetBool(varForceTty)
if forceTty && err == nil {
return true
}
file, ok := inv.Stdin.(*os.File)
if !ok {
return false
}
return isatty.IsTerminal(file.Fd())
}
// isTTYOut returns whether the passed reader is a TTY or not.
func isTTYOut(inv *serpent.Invocation) bool {
return isTTYWriter(inv, inv.Stdout)
}
// isTTYErr returns whether the passed reader is a TTY or not.
func isTTYErr(inv *serpent.Invocation) bool {
return isTTYWriter(inv, inv.Stderr)
}
func isTTYWriter(inv *serpent.Invocation, writer io.Writer) bool {
// If the `--force-tty` command is available, and set,
// assume we're in a tty. This is primarily for cases on Windows
// where we may not be able to reliably detect this automatically (ie, tests)
forceTty, err := inv.ParsedFlags().GetBool(varForceTty)
if forceTty && err == nil {
return true
}
file, ok := writer.(*os.File)
if !ok {
return false
}
return isatty.IsTerminal(file.Fd())
}
// example represents a standard example for command usage, to be used
// with formatExamples.
type example struct {
Description string
Command string
}
// formatExamples formats the examples as width wrapped bulletpoint
// descriptions with the command underneath.
func formatExamples(examples ...example) string {
var sb strings.Builder
padStyle := cliui.DefaultStyles.Wrap.With(pretty.XPad(4, 0))
for i, e := range examples {
if len(e.Description) > 0 {
wordwrap.WrapString(e.Description, 80)
_, _ = sb.WriteString(
" - " + pretty.Sprint(padStyle, e.Description+":")[4:] + "\n\n ",
)
}
// We add 1 space here because `cliui.DefaultStyles.Code` adds an extra
// space. This makes the code block align at an even 2 or 6
// spaces for symmetry.
_, _ = sb.WriteString(" " + pretty.Sprint(cliui.DefaultStyles.Code, fmt.Sprintf("$ %s", e.Command)))
if i < len(examples)-1 {
_, _ = sb.WriteString("\n\n")
}
}
return sb.String()
}
// Verbosef logs a message if verbose mode is enabled.
func (r *RootCmd) Verbosef(inv *serpent.Invocation, fmtStr string, args ...interface{}) {
if r.verbose {
cliui.Infof(inv.Stdout, fmtStr, args...)
}
}
// DumpHandler provides a custom SIGQUIT and SIGTRAP handler that dumps the
// stacktrace of all goroutines to stderr and a well-known file in the home
// directory. This is useful for debugging deadlock issues that may occur in
// production in workspaces, since the default Go runtime will only dump to
// stderr (which is often difficult/impossible to read in a workspace).
//
// SIGQUITs will still cause the program to exit (similarly to the default Go
// runtime behavior).
//
// A SIGQUIT handler will not be registered if GOTRACEBACK=crash.
//
// On Windows this immediately returns.
func DumpHandler(ctx context.Context, name string) {
if runtime.GOOS == "windows" {
// free up the goroutine since it'll be permanently blocked anyways
return
}
listenSignals := []os.Signal{syscall.SIGTRAP}
if os.Getenv("GOTRACEBACK") != "crash" {
listenSignals = append(listenSignals, syscall.SIGQUIT)
}
sigs := make(chan os.Signal, 1)
signal.Notify(sigs, listenSignals...)
defer signal.Stop(sigs)
for {
sigStr := ""
select {
case <-ctx.Done():
return
case sig := <-sigs:
switch sig {
case syscall.SIGQUIT:
sigStr = "SIGQUIT"
case syscall.SIGTRAP:
sigStr = "SIGTRAP"
}
}
// Start with a 1MB buffer and keep doubling it until we can fit the
// entire stacktrace, stopping early once we reach 64MB.
buf := make([]byte, 1_000_000)
stacklen := 0
for {
stacklen = runtime.Stack(buf, true)
if stacklen < len(buf) {
break
}
if 2*len(buf) > 64_000_000 {
// Write a message to the end of the buffer saying that it was
// truncated.
const truncatedMsg = "\n\n\nstack trace truncated due to size\n"
copy(buf[len(buf)-len(truncatedMsg):], truncatedMsg)
break
}
buf = make([]byte, 2*len(buf))
}
_, _ = fmt.Fprintf(os.Stderr, "%s:\n%s\n", sigStr, buf[:stacklen])
// Write to a well-known file.
dir, err := os.UserHomeDir()
if err != nil {
dir = os.TempDir()
}
// Make the time filesystem-safe, for example ":" is not
// permitted on many filesystems. Note that Z here only appends
// Z to the string, it does not actually change the time zone.
filesystemSafeTime := time.Now().UTC().Format("2006-01-02T15-04-05.000Z")
fpath := filepath.Join(dir, fmt.Sprintf("coder-%s-%s.dump", name, filesystemSafeTime))
_, _ = fmt.Fprintf(os.Stderr, "writing dump to %q\n", fpath)
f, err := os.Create(fpath)
if err != nil {
_, _ = fmt.Fprintf(os.Stderr, "failed to open dump file: %v\n", err.Error())
goto done
}
_, err = f.Write(buf[:stacklen])
_ = f.Close()
if err != nil {
_, _ = fmt.Fprintf(os.Stderr, "failed to write dump file: %v\n", err.Error())
goto done
}
done:
if sigStr == "SIGQUIT" {
//nolint:revive
os.Exit(1)
}
}
}
type exitError struct {
code int
err error
}
var _ error = (*exitError)(nil)
func (e *exitError) Error() string {
if e.err != nil {
return fmt.Sprintf("exit code %d: %v", e.code, e.err)
}
return fmt.Sprintf("exit code %d", e.code)
}
func (e *exitError) Unwrap() error {
return e.err
}
// ExitError returns an error that will cause the CLI to exit with the given
// exit code. If err is non-nil, it will be wrapped by the returned error.
func ExitError(code int, err error) error {
return &exitError{code: code, err: err}
}
// NewPrettyErrorFormatter creates a new PrettyErrorFormatter.
func NewPrettyErrorFormatter(w io.Writer, verbose bool) *PrettyErrorFormatter {
return &PrettyErrorFormatter{
w: w,
verbose: verbose,
}
}
type PrettyErrorFormatter struct {
w io.Writer
// verbose turns on more detailed error logs, such as stack traces.
verbose bool
}
// Format formats the error to the writer in PrettyErrorFormatter.
// This error should be human readable.
func (p *PrettyErrorFormatter) Format(err error) {
output, _ := cliHumanFormatError("", err, &formatOpts{
Verbose: p.verbose,
})
// always trail with a newline
_, _ = p.w.Write([]byte(output + "\n"))
}
type formatOpts struct {
Verbose bool
}
const indent = " "
// cliHumanFormatError formats an error for the CLI. Newlines and styling are
// included. The second return value is true if the error is special and the error
// chain has custom formatting applied.
//
// If you change this code, you can use the cli "example-errors" tool to
// verify all errors still look ok.
//
// go run main.go exp example-error <type>
// go run main.go exp example-error api
// go run main.go exp example-error cmd
// go run main.go exp example-error multi-error
// go run main.go exp example-error validation
//
//nolint:errorlint
func cliHumanFormatError(from string, err error, opts *formatOpts) (string, bool) {
if opts == nil {
opts = &formatOpts{}
}
if err == nil {
return "<nil>", true
}
if multi, ok := err.(interface{ Unwrap() []error }); ok {
multiErrors := multi.Unwrap()
if len(multiErrors) == 1 {
// Format as a single error
return cliHumanFormatError(from, multiErrors[0], opts)
}
return formatMultiError(from, multiErrors, opts), true
}
// First check for sentinel errors that we want to handle specially.
// Order does matter! We want to check for the most specific errors first.
if sdkError, ok := err.(*codersdk.Error); ok {
return formatCoderSDKError(from, sdkError, opts), true
}
if cmdErr, ok := err.(*serpent.RunCommandError); ok {
// no need to pass the "from" context to this since it is always
// top level. We care about what is below this.
return formatRunCommandError(cmdErr, opts), true
}
uw, ok := err.(interface{ Unwrap() error })
if ok {
msg, special := cliHumanFormatError(from+traceError(err), uw.Unwrap(), opts)
if special {
return msg, special
}
}
// If we got here, that means that the wrapped error chain does not have
// any special formatting below it. So we want to return the topmost non-special
// error (which is 'err')
// Default just printing the error. Use +v for verbose to handle stack
// traces of xerrors.
if opts.Verbose {
return pretty.Sprint(headLineStyle(), fmt.Sprintf("%+v", err)), false
}
return pretty.Sprint(headLineStyle(), fmt.Sprintf("%v", err)), false
}
// formatMultiError formats a multi-error. It formats it as a list of errors.
//
// Multiple Errors:
// <# errors encountered>:
// 1. <heading error message>
// <verbose error message>
// 2. <heading error message>
// <verbose error message>
func formatMultiError(from string, multi []error, opts *formatOpts) string {
var errorStrings []string
for _, err := range multi {
msg, _ := cliHumanFormatError("", err, opts)
errorStrings = append(errorStrings, msg)
}
// Write errors out
var str strings.Builder
var traceMsg string
if from != "" {
traceMsg = fmt.Sprintf("Trace=[%s])", from)
}
_, _ = str.WriteString(pretty.Sprint(headLineStyle(), fmt.Sprintf("%d errors encountered: %s", len(multi), traceMsg)))
for i, errStr := range errorStrings {
// Indent each error
errStr = strings.ReplaceAll(errStr, "\n", "\n"+indent)
// Error now looks like
// | <line>
// | <line>
prefix := fmt.Sprintf("%d. ", i+1)
if len(prefix) < len(indent) {
// Indent the prefix to match the indent
prefix = prefix + strings.Repeat(" ", len(indent)-len(prefix))
}
errStr = prefix + errStr
// Now looks like
// |1.<line>
// | <line>
_, _ = str.WriteString("\n" + errStr)
}
return str.String()
}
// formatRunCommandError are cli command errors. This kind of error is very
// broad, as it contains all errors that occur when running a command.
// If you know the error is something else, like a codersdk.Error, make a new
// formatter and add it to cliHumanFormatError function.
func formatRunCommandError(err *serpent.RunCommandError, opts *formatOpts) string {
var str strings.Builder
_, _ = str.WriteString(pretty.Sprint(headLineStyle(),
fmt.Sprintf(
`Encountered an error running %q, see "%s --help" for more information`,
err.Cmd.FullName(), err.Cmd.FullName())))
_, _ = str.WriteString(pretty.Sprint(headLineStyle(), "\nerror: "))
msgString, special := cliHumanFormatError("", err.Err, opts)
if special {
_, _ = str.WriteString(msgString)
} else {
_, _ = str.WriteString(pretty.Sprint(tailLineStyle(), msgString))
}
return str.String()
}
// formatCoderSDKError come from API requests. In verbose mode, add the
// request debug information.
func formatCoderSDKError(from string, err *codersdk.Error, opts *formatOpts) string {
var str strings.Builder
if opts.Verbose {
// If all these fields are empty, then do not print this information.
// This can occur if the error is being used outside the api.
if !(err.Method() == "" && err.URL() == "" && err.StatusCode() == 0) {
_, _ = str.WriteString(pretty.Sprint(headLineStyle(), fmt.Sprintf("API request error to \"%s:%s\". Status code %d", err.Method(), err.URL(), err.StatusCode())))
_, _ = str.WriteString("\n")
}
}
// Always include this trace. Users can ignore this.
if from != "" {
_, _ = str.WriteString(pretty.Sprint(headLineStyle(), fmt.Sprintf("Trace=[%s]", from)))
_, _ = str.WriteString("\n")
}
// The main error message
_, _ = str.WriteString(pretty.Sprint(headLineStyle(), err.Message))
// Validation errors.
if len(err.Validations) > 0 {
_, _ = str.WriteString("\n")
_, _ = str.WriteString(pretty.Sprint(tailLineStyle(), fmt.Sprintf("%d validation error(s) found", len(err.Validations))))
for _, e := range err.Validations {
_, _ = str.WriteString("\n\t")
_, _ = str.WriteString(pretty.Sprint(cliui.DefaultStyles.Field, e.Field))
_, _ = str.WriteString(pretty.Sprintf(cliui.DefaultStyles.Warn, ": %s", e.Detail))
}
}
if err.Helper != "" {
_, _ = str.WriteString("\n")
_, _ = str.WriteString(pretty.Sprintf(tailLineStyle(), "Suggestion: %s", err.Helper))
}
// By default we do not show the Detail with the helper.
if opts.Verbose || (err.Helper == "" && err.Detail != "") {
_, _ = str.WriteString("\n")
_, _ = str.WriteString(pretty.Sprint(tailLineStyle(), err.Detail))
}
return str.String()
}
// traceError is a helper function that aides developers debugging failed cli
// commands. When we pretty print errors, we lose the context in which they came.
// This function adds the context back. Unfortunately there is no easy way to get
// the prefix to: "error string: %w", so we do a bit of string manipulation.
//
//nolint:errorlint
func traceError(err error) string {
if uw, ok := err.(interface{ Unwrap() error }); ok {
a, b := err.Error(), uw.Unwrap().Error()
c := strings.TrimSuffix(a, b)
return c
}
return err.Error()
}
// These styles are arbitrary.
func headLineStyle() pretty.Style {
return cliui.DefaultStyles.Error
}
func tailLineStyle() pretty.Style {
return pretty.Style{pretty.Nop}
}
//nolint:unused
func SlimUnsupported(w io.Writer, cmd string) {
_, _ = fmt.Fprintf(w, "You are using a 'slim' build of Coder, which does not support the %s subcommand.\n", pretty.Sprint(cliui.DefaultStyles.Code, cmd))
_, _ = fmt.Fprintln(w, "")
_, _ = fmt.Fprintln(w, "Please use a build of Coder from GitHub releases:")
_, _ = fmt.Fprintln(w, " https://github.com/coder/coder/releases")
//nolint:revive
os.Exit(1)
}
func defaultUpgradeMessage(version string) string {
// Our installation script doesn't work on Windows, so instead we direct the user
// to the GitHub release page to download the latest installer.
version = strings.TrimPrefix(version, "v")
if runtime.GOOS == "windows" {
return fmt.Sprintf("download the server version from: https://github.com/coder/coder/releases/v%s", version)
}
return fmt.Sprintf("download the server version with: 'curl -L https://coder.com/install.sh | sh -s -- --version %s'", version)
}
// wrapTransportWithEntitlementsCheck adds a middleware to the HTTP transport
// that checks for entitlement warnings and prints them to the user.
func wrapTransportWithEntitlementsCheck(rt http.RoundTripper, w io.Writer) http.RoundTripper {
var once sync.Once
return roundTripper(func(req *http.Request) (*http.Response, error) {
res, err := rt.RoundTrip(req)
if err != nil {
return res, err
}
once.Do(func() {
for _, warning := range res.Header.Values(codersdk.EntitlementsWarningHeader) {
_, _ = fmt.Fprintln(w, pretty.Sprint(cliui.DefaultStyles.Warn, warning))
}
})
return res, err
})
}
// wrapTransportWithVersionMismatchCheck adds a middleware to the HTTP transport
// that checks for version mismatches between the client and server. If a mismatch
// is detected, a warning is printed to the user.
func wrapTransportWithVersionMismatchCheck(rt http.RoundTripper, inv *serpent.Invocation, clientVersion string, getBuildInfo func(ctx context.Context) (codersdk.BuildInfoResponse, error)) http.RoundTripper {
var once sync.Once
return roundTripper(func(req *http.Request) (*http.Response, error) {
res, err := rt.RoundTrip(req)
if err != nil {
return res, err
}
once.Do(func() {
serverVersion := res.Header.Get(codersdk.BuildVersionHeader)
if serverVersion == "" {
return
}
if buildinfo.VersionsMatch(clientVersion, serverVersion) {
return
}
upgradeMessage := defaultUpgradeMessage(semver.Canonical(serverVersion))
serverInfo, err := getBuildInfo(inv.Context())
if err == nil && serverInfo.UpgradeMessage != "" {
upgradeMessage = serverInfo.UpgradeMessage
}
fmtWarningText := "version mismatch: client %s, server %s\n%s"
fmtWarn := pretty.Sprint(cliui.DefaultStyles.Warn, fmtWarningText)
warning := fmt.Sprintf(fmtWarn, clientVersion, serverVersion, upgradeMessage)
_, _ = fmt.Fprintln(inv.Stderr, warning)
})
return res, err
})
}
// wrapTransportWithTelemetryHeader adds telemetry headers to report command usage
// to an HTTP transport.
func wrapTransportWithTelemetryHeader(transport http.RoundTripper, inv *serpent.Invocation) http.RoundTripper {
var (
value string
once sync.Once
)
return roundTripper(func(req *http.Request) (*http.Response, error) {
once.Do(func() {
// We only want to compute this header once when a request
// first goes out, hence the complexity with locking here.
var topts []telemetry.Option
for _, opt := range inv.Command.FullOptions() {
if opt.ValueSource == serpent.ValueSourceNone || opt.ValueSource == serpent.ValueSourceDefault {
continue
}
topts = append(topts, telemetry.Option{
Name: opt.Name,
ValueSource: string(opt.ValueSource),
})
}
ti := telemetry.Invocation{
Command: inv.Command.FullName(),
Options: topts,
InvokedAt: time.Now(),
}
byt, err := json.Marshal(ti)
if err != nil {
// Should be impossible
panic(err)
}
s := base64.StdEncoding.EncodeToString(byt)
// Don't send the header if it's too long!
if len(s) <= 4096 {
value = s
}
})
if value != "" {
req.Header.Add(codersdk.CLITelemetryHeader, value)
}
return transport.RoundTrip(req)
})
}
type roundTripper func(req *http.Request) (*http.Response, error)
func (r roundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
return r(req)
}