package cli import ( "bufio" "context" "errors" "flag" "fmt" "io" "math/rand" "net" "net/http" "net/url" "os" "os/signal" "path/filepath" "regexp" "runtime" "strings" "syscall" "time" "unicode/utf8" "golang.org/x/crypto/ssh/terminal" "golang.org/x/exp/slices" "golang.org/x/xerrors" "cdr.dev/slog" "github.com/charmbracelet/lipgloss" "github.com/mattn/go-isatty" "github.com/coder/coder/buildinfo" "github.com/coder/coder/cli/clibase" "github.com/coder/coder/cli/cliui" "github.com/coder/coder/cli/config" "github.com/coder/coder/coderd" "github.com/coder/coder/coderd/gitauth" "github.com/coder/coder/codersdk" "github.com/coder/coder/codersdk/agentsdk" ) var ( Caret = cliui.Styles.Prompt.String() // 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" varAgentURL = "agent-url" varHeader = "header" varNoOpen = "no-open" varNoVersionCheck = "no-version-warning" varNoFeatureWarning = "no-feature-warning" varForceTty = "force-tty" varVerbose = "verbose" notLoggedInMessage = "You are not logged in. Try logging in using 'coder login '." envNoVersionCheck = "CODER_NO_VERSION_WARNING" envNoFeatureWarning = "CODER_NO_FEATURE_WARNING" envSessionToken = "CODER_SESSION_TOKEN" envURL = "CODER_URL" ) var errUnauthenticated = xerrors.New(notLoggedInMessage) func (r *RootCmd) Core() []*clibase.Cmd { // Please re-sort this list alphabetically if you change it! return []*clibase.Cmd{ r.dotfiles(), r.login(), r.logout(), r.portForward(), r.publickey(), r.resetPassword(), r.state(), r.templates(), r.users(), r.tokens(), r.version(), // Workspace Commands r.configSSH(), r.rename(), r.ping(), r.create(), r.deleteWorkspace(), r.list(), r.schedules(), r.show(), r.speedtest(), r.ssh(), r.start(), r.stop(), r.update(), r.restart(), r.parameters(), // Hidden r.workspaceAgent(), r.scaletest(), r.gitssh(), r.vscodeSSH(), } } func (r *RootCmd) AGPL() []*clibase.Cmd { all := append(r.Core(), r.Server(func(_ context.Context, o *coderd.Options) (*coderd.API, io.Closer, error) { api := coderd.New(o) return api, api, nil })) return all } // Main is the entrypoint for the Coder CLI. func (r *RootCmd) RunMain(subcommands []*clibase.Cmd) { rand.Seed(time.Now().UnixMicro()) cmd, err := r.Command(subcommands) if err != nil { panic(err) } err = cmd.Invoke().WithOS().Run() if err != nil { if errors.Is(err, cliui.Canceled) { //nolint:revive os.Exit(1) } f := prettyErrorFormatter{w: os.Stderr} f.format(err) //nolint:revive os.Exit(1) } } func (r *RootCmd) Command(subcommands []*clibase.Cmd) (*clibase.Cmd, error) { fmtLong := `Coder %s — A tool for provisioning self-hosted development environments with Terraform. ` cmd := &clibase.Cmd{ Use: "coder [global-flags] ", 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 *clibase.Invocation) error { // fmt.Fprintf(i.Stderr, "env debug: %+v", i.Environ) // 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 *clibase.Cmd) { if c.HelpHandler == nil { c.HelpHandler = helpFn() } }) var merr error // Add [flags] to usage when appropriate. cmd.Walk(func(cmd *clibase.Cmd) { 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 *clibase.Cmd) { // 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 *clibase.Cmd) { 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 } if r.agentURL == nil { r.agentURL = new(url.URL) } if r.clientURL == nil { r.clientURL = new(url.URL) } globalGroup := &clibase.Group{ Name: "Global", Description: `Global options are applied to all commands. They can be set using environment variables or flags.`, } cmd.Options = clibase.OptionSet{ { Flag: varURL, Env: envURL, Description: "URL to a deployment.", Value: clibase.URLOf(r.clientURL), Group: globalGroup, }, { Flag: varToken, Env: envSessionToken, Description: fmt.Sprintf("Specify an authentication token. For security reasons setting %s is preferred.", envSessionToken), Value: clibase.StringOf(&r.token), Group: globalGroup, }, { Flag: varAgentToken, Description: "An agent authentication token.", Value: clibase.StringOf(&r.agentToken), Hidden: true, Group: globalGroup, }, { Flag: varAgentURL, Env: "CODER_AGENT_URL", Description: "URL for an agent to access your deployment.", Value: clibase.URLOf(r.agentURL), Hidden: true, Group: globalGroup, }, { Flag: varNoVersionCheck, Env: envNoVersionCheck, Description: "Suppress warning when client and server versions do not match.", Value: clibase.BoolOf(&r.noVersionCheck), Group: globalGroup, }, { Flag: varNoFeatureWarning, Env: envNoFeatureWarning, Description: "Suppress warnings about unlicensed features.", Value: clibase.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: clibase.StringArrayOf(&r.header), Group: globalGroup, }, { Flag: varNoOpen, Env: "CODER_NO_OPEN", Description: "Suppress opening the browser after logging in.", Value: clibase.BoolOf(&r.noOpen), Hidden: true, Group: globalGroup, }, { Flag: varForceTty, Env: "CODER_FORCE_TTY", Hidden: true, Description: "Force the use of a TTY.", Value: clibase.BoolOf(&r.forceTTY), Group: globalGroup, }, { Flag: varVerbose, FlagShorthand: "v", Env: "CODER_VERBOSE", Description: "Enable verbose output.", Value: clibase.BoolOf(&r.verbose), Group: globalGroup, }, { Flag: config.FlagName, Env: "CODER_CONFIG_DIR", Description: "Path to the global `coder` config directory.", Default: config.DefaultDir(), Value: clibase.StringOf(&r.globalConfig), Group: globalGroup, }, } err := cmd.PrepareAll() if err != nil { return nil, err } return cmd, nil } type contextKey int const ( contextKeyLogger contextKey = iota ) func ContextWithLogger(ctx context.Context, l slog.Logger) context.Context { return context.WithValue(ctx, contextKeyLogger, l) } func LoggerFromContext(ctx context.Context) (slog.Logger, bool) { l, ok := ctx.Value(contextKeyLogger).(slog.Logger) return l, ok } // version prints the coder version func (*RootCmd) version() *clibase.Cmd { return &clibase.Cmd{ Use: "version", Short: "Show coder version", Handler: func(inv *clibase.Invocation) error { var str strings.Builder _, _ = str.WriteString("Coder ") if buildinfo.IsAGPL() { _, _ = str.WriteString("(AGPL) ") } _, _ = str.WriteString(buildinfo.Version()) buildTime, valid := buildinfo.Time() if valid { _, _ = str.WriteString(" " + buildTime.Format(time.UnixDate)) } _, _ = str.WriteString("\r\n" + buildinfo.ExternalURL() + "\r\n\r\n") if buildinfo.IsSlim() { _, _ = str.WriteString(fmt.Sprintf("Slim build of Coder, does not support the %s subcommand.\n", cliui.Styles.Code.Render("server"))) } else { _, _ = str.WriteString(fmt.Sprintf("Full build of Coder, supports the %s subcommand.\n", cliui.Styles.Code.Render("server"))) } _, _ = fmt.Fprint(inv.Stdout, str.String()) return nil }, } } func isTest() bool { return flag.Lookup("test.v") != nil } // RootCmd contains parameters and helpers useful to all commands. type RootCmd struct { clientURL *url.URL token string globalConfig string header []string agentToken string agentURL *url.URL forceTTY bool noOpen bool verbose bool noVersionCheck bool noFeatureWarning bool } // InitClient sets client to a new client. // It reads from global configuration files if flags are not set. func (r *RootCmd) InitClient(client *codersdk.Client) clibase.MiddlewareFunc { if client == nil { panic("client is nil") } if r == nil { panic("root is nil") } return func(next clibase.HandlerFunc) clibase.HandlerFunc { return func(i *clibase.Invocation) error { conf := r.createConfig() var err error 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 (errUnauthenticated) } if err != nil { return err } r.clientURL, err = url.Parse(strings.TrimSpace(rawURL)) if err != nil { return err } } if r.token == "" { r.token, err = conf.Session().Read() // If the configuration files are absent, the user is logged out if os.IsNotExist(err) { return (errUnauthenticated) } if err != nil { return err } } err = r.setClient(client, r.clientURL) if err != nil { return err } client.SetSessionToken(r.token) // We send these requests in parallel to minimize latency. var ( versionErr = make(chan error) warningErr = make(chan error) ) go func() { versionErr <- r.checkVersions(i, client) close(versionErr) }() go func() { warningErr <- r.checkWarnings(i, client) close(warningErr) }() if err = <-versionErr; err != nil { // Just log the error here. We never want to fail a command // due to a pre-run. _, _ = fmt.Fprintf(i.Stderr, cliui.Styles.Warn.Render("check versions error: %s"), err) _, _ = fmt.Fprintln(i.Stderr) } if err = <-warningErr; err != nil { // Same as above _, _ = fmt.Fprintf(i.Stderr, cliui.Styles.Warn.Render("check entitlement warnings error: %s"), err) _, _ = fmt.Fprintln(i.Stderr) } return next(i) } } } func (r *RootCmd) setClient(client *codersdk.Client, serverURL *url.URL) error { transport := &headerTransport{ transport: http.DefaultTransport, header: http.Header{}, } for _, header := range r.header { parts := strings.SplitN(header, "=", 2) if len(parts) < 2 { return xerrors.Errorf("split header %q had less than two parts", header) } transport.header.Add(parts[0], parts[1]) } client.URL = serverURL client.HTTPClient = &http.Client{ Transport: transport, } return nil } func (r *RootCmd) createUnauthenticatedClient(serverURL *url.URL) (*codersdk.Client, error) { var client codersdk.Client err := r.setClient(&client, serverURL) 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(inv *clibase.Invocation, client *codersdk.Client) (codersdk.Organization, error) { orgs, err := client.OrganizationsByUser(inv.Context(), codersdk.Me) if err != nil { return codersdk.Organization{}, nil } // For now, we won't use the config to set this. // Eventually, we will support changing using "coder switch " return orgs[0], 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) { parts := strings.Split(identifier, "/") var owner, name string switch len(parts) { case 1: owner = codersdk.Me name = parts[0] case 2: owner = parts[0] name = parts[1] default: return codersdk.Workspace{}, xerrors.Errorf("invalid workspace name: %q", identifier) } 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 *clibase.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 *clibase.Invocation) bool { return isTTYWriter(inv, inv.Stdout) } // isTTYErr returns whether the passed reader is a TTY or not. func isTTYErr(inv *clibase.Invocation) bool { return isTTYWriter(inv, inv.Stderr) } func isTTYWriter(inv *clibase.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 { wrap := cliui.Styles.Wrap.Copy() wrap.PaddingLeft(4) var sb strings.Builder for i, e := range examples { if len(e.Description) > 0 { _, _ = sb.WriteString(" - " + wrap.Render(e.Description + ":")[4:] + "\n\n ") } // We add 1 space here because `cliui.Styles.Code` adds an extra // space. This makes the code block align at an even 2 or 6 // spaces for symmetry. _, _ = sb.WriteString(" " + cliui.Styles.Code.Render(fmt.Sprintf("$ %s", e.Command))) if i < len(examples)-1 { _, _ = sb.WriteString("\n\n") } } return sb.String() } func (r *RootCmd) checkVersions(i *clibase.Invocation, client *codersdk.Client) error { if r.noVersionCheck { return nil } ctx, cancel := context.WithTimeout(i.Context(), 10*time.Second) defer cancel() clientVersion := buildinfo.Version() info, err := client.BuildInfo(ctx) // Avoid printing errors that are connection-related. if isConnectionError(err) { return nil } if err != nil { return xerrors.Errorf("build info: %w", err) } fmtWarningText := `version mismatch: client %s, server %s ` // Our installation script doesn't work on Windows, so instead we direct the user // to the GitHub release page to download the latest installer. if runtime.GOOS == "windows" { fmtWarningText += `download the server version from: https://github.com/coder/coder/releases/v%s` } else { fmtWarningText += `download the server version with: 'curl -L https://coder.com/install.sh | sh -s -- --version %s'` } if !buildinfo.VersionsMatch(clientVersion, info.Version) { warn := cliui.Styles.Warn.Copy().Align(lipgloss.Left) _, _ = fmt.Fprintf(i.Stderr, warn.Render(fmtWarningText), clientVersion, info.Version, strings.TrimPrefix(info.CanonicalVersion(), "v")) _, _ = fmt.Fprintln(i.Stderr) } return nil } func (r *RootCmd) checkWarnings(i *clibase.Invocation, client *codersdk.Client) error { if r.noFeatureWarning { return nil } ctx, cancel := context.WithTimeout(i.Context(), 10*time.Second) defer cancel() entitlements, err := client.Entitlements(ctx) if err == nil { for _, w := range entitlements.Warnings { _, _ = fmt.Fprintln(i.Stderr, cliui.Styles.Warn.Render(w)) } } return nil } type headerTransport struct { transport http.RoundTripper header http.Header } func (h *headerTransport) Header() http.Header { return h.header.Clone() } func (h *headerTransport) RoundTrip(req *http.Request) (*http.Response, error) { for k, v := range h.header { for _, vv := range v { req.Header.Add(k, vv) } } return h.transport.RoundTrip(req) } // 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) { 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() } fpath := filepath.Join(dir, fmt.Sprintf("coder-agent-%s.dump", time.Now().Format("2006-01-02T15:04:05.000Z"))) _, _ = 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) } } } // IiConnectionErr is a convenience function for checking if the source of an // error is due to a 'connection refused', 'no such host', etc. func isConnectionError(err error) bool { var ( // E.g. no such host dnsErr *net.DNSError // Eg. connection refused opErr *net.OpError ) return xerrors.As(err, &dnsErr) || xerrors.As(err, &opErr) } type prettyErrorFormatter struct { level int w io.Writer } func (prettyErrorFormatter) prefixLines(spaces int, s string) string { twidth, _, err := terminal.GetSize(0) if err != nil { twidth = 80 } s = lipgloss.NewStyle().Width(twidth - spaces).Render(s) var b strings.Builder scanner := bufio.NewScanner(strings.NewReader(s)) for i := 0; scanner.Scan(); i++ { // The first line is already padded. if i == 0 { _, _ = fmt.Fprintf(&b, "%s\n", scanner.Text()) continue } _, _ = fmt.Fprintf(&b, "%s%s\n", strings.Repeat(" ", spaces), scanner.Text()) } return strings.TrimSuffix(strings.TrimSuffix(b.String(), "\n"), " ") } func (p *prettyErrorFormatter) format(err error) { underErr := errors.Unwrap(err) arrowStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#515151")) //nolint:errorlint if _, ok := err.(*clibase.RunCommandError); ok && p.level == 0 && underErr != nil { // We can do a better job now. p.format(underErr) return } var ( padding string arrowWidth int ) if p.level > 0 { const arrow = "┗━ " arrowWidth = utf8.RuneCount([]byte(arrow)) padding = strings.Repeat(" ", arrowWidth*p.level) _, _ = fmt.Fprintf(p.w, "%v%v", padding, arrowStyle.Render(arrow)) } if underErr != nil { header := strings.TrimSuffix(err.Error(), ": "+underErr.Error()) _, _ = fmt.Fprintf(p.w, "%s\n", p.prefixLines(len(padding)+arrowWidth, header)) p.level++ p.format(underErr) return } { style := lipgloss.NewStyle().Foreground(lipgloss.Color("#D16644")).Background(lipgloss.Color("#000000")).Bold(false) // This is the last error in a tree. p.wrappedPrintf( "%s\n", p.prefixLines( len(padding)+arrowWidth, fmt.Sprintf( "%s%s%s", lipgloss.NewStyle().Inherit(style).Underline(true).Render("ERROR"), lipgloss.NewStyle().Inherit(style).Foreground(arrowStyle.GetForeground()).Render(" ► "), style.Render(err.Error()), ), ), ) } } func (p *prettyErrorFormatter) wrappedPrintf(format string, a ...interface{}) { s := lipgloss.NewStyle().Width(ttyWidth()).Render( fmt.Sprintf(format, a...), ) // Not sure why, but lipgloss is adding extra spaces we need to remove. excessSpaceRe := regexp.MustCompile(`[[:blank:]]*\n[[:blank:]]*$`) s = excessSpaceRe.ReplaceAllString(s, "\n") _, _ = p.w.Write( []byte( s, ), ) }