Files
coder/cli/root.go
T
Jon Ayers 797dc6b2d1 feat: refresh header commands based on JWT expiration (#29760)
Adds opportunistic JWT parsing for values returned from the
`--header-command`. If a JWT is parsed we use the value of its `exp`
field to determine when we should re-run the command to refresh the
values.
2026-09-28 09:42:31 -05:00

2009 lines
64 KiB
Go

package cli
import (
"context"
"crypto/tls"
"crypto/x509"
"encoding/base64"
"encoding/json"
"errors"
"flag"
"fmt"
"io"
"net/http"
"net/url"
"os"
"os/signal"
"path/filepath"
"runtime"
"runtime/trace"
"slices"
"strings"
"sync"
"syscall"
"text/tabwriter"
"time"
"github.com/mattn/go-isatty"
"github.com/mitchellh/go-wordwrap"
"go.opentelemetry.io/otel/baggage"
"go.opentelemetry.io/otel/propagation"
"golang.org/x/mod/semver"
"golang.org/x/xerrors"
"cdr.dev/slog/v3"
"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/sessionstore"
"github.com/coder/coder/v2/cli/telemetry"
"github.com/coder/coder/v2/coderd/tracing"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/codersdk/agentsdk"
"github.com/coder/pretty"
"github.com/coder/quartz"
"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": "",
}
// ErrSilent is a sentinel error that tells the command handler to just exit with a non-zero error, but not print
// anything.
ErrSilent = xerrors.New("silent error")
ErrClientURLNotConfigured = xerrors.New("client URL is not configured")
errKeyringNotSupported = xerrors.New("keyring storage is not supported on this operating system; omit --use-keyring to use file-based storage")
)
const (
varURL = "url"
varToken = "token"
varHeader = "header"
varHeaderCommand = "header-command"
varNoOpen = "no-open"
varNoVersionCheck = "no-version-warning"
varNoFeatureWarning = "no-feature-warning"
varAllowRedirects = "allow-redirects"
varForceTty = "force-tty"
varVerbose = "verbose"
varDisableDirect = "disable-direct-connections"
varDisableNetworkTelemetry = "disable-network-telemetry"
varUseKeyring = "use-keyring"
varClientTLSCAFile = "client-tls-ca-file"
varClientTLSCertFile = "client-tls-cert-file"
varClientTLSKeyFile = "client-tls-key-file"
notLoggedInMessage = "You are not logged in. Try logging in using '%s login <url>'."
envNoVersionCheck = "CODER_NO_VERSION_WARNING"
envNoFeatureWarning = "CODER_NO_FEATURE_WARNING"
envAllowRedirects = "CODER_ALLOW_REDIRECTS"
envSessionToken = "CODER_SESSION_TOKEN"
envUseKeyring = "CODER_USE_KEYRING"
envClientTLSCAFile = "CODER_CLIENT_TLS_CA_FILE"
envClientTLSCertFile = "CODER_CLIENT_TLS_CERT_FILE"
envClientTLSKeyFile = "CODER_CLIENT_TLS_KEY_FILE"
//nolint:gosec
envAgentToken = "CODER_AGENT_TOKEN"
//nolint:gosec
envAgentTokenFile = "CODER_AGENT_TOKEN_FILE"
envAgentURL = "CODER_AGENT_URL"
envAgentAuth = "CODER_AGENT_AUTH"
envAgentName = "CODER_AGENT_NAME"
envURL = "CODER_URL"
)
func (r *RootCmd) CoreSubcommands() []*serpent.Command {
// Please re-sort this list alphabetically if you change it!
return []*serpent.Command{
r.completion(),
r.dotfiles(),
externalAuth(),
r.login(),
r.logout(),
r.netcheck(),
r.notifications(),
r.oauth2Provider(),
r.organizations(),
r.portForward(),
r.publickey(),
r.resetPassword(),
r.secrets(),
r.sharing(),
r.state(),
r.templates(),
r.tokens(),
r.users(),
r.version(defaultVersionInfo),
// Workspace Commands
r.autoupdate(),
r.configSSH(),
r.Create(CreateOptions{}),
r.deleteWorkspace(),
r.favorite(),
r.list(),
r.logs(),
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(),
r.whoami(),
// Hidden
r.connectCmd(),
gitssh(),
r.support(),
r.vpnDaemon(),
r.vscodeSSH(),
workspaceAgent(),
}
}
// AGPLExperimental returns all AGPL experimental subcommands.
func (r *RootCmd) AGPLExperimental() []*serpent.Command {
return []*serpent.Command{
r.scaletestCmd(),
r.errorExample(),
r.chatCommand(),
r.mcpCommand(),
r.promptExample(),
r.rptyCommand(),
r.syncCommand(),
r.updateUserEmail(),
}
}
// AGPL returns all AGPL commands including any non-core commands that are
// duplicated in the Enterprise CLI.
func (r *RootCmd) AGPL() []*serpent.Command {
all := append(
r.CoreSubcommands(),
r.Server( /* Do not import coderd here. */ nil),
r.Provisioners(),
ExperimentalCommand(r.AGPLExperimental()),
)
return all
}
// ExperimentalCommand creates an experimental command that is hidden and has
// the given subcommands.
func ExperimentalCommand(subcommands []*serpent.Command) *serpent.Command {
cmd := &serpent.Command{
Use: "exp",
Short: "Internal commands for testing and experimentation. These are prone to breaking changes with no notice.",
Handler: func(i *serpent.Invocation) error {
return i.Command.HelpHandler(i)
},
Hidden: true,
Children: subcommands,
}
return cmd
}
// 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.ErrCanceled) {
//nolint:revive,gocritic
os.Exit(code)
}
if errors.Is(err, ErrSilent) {
//nolint:revive,gocritic
os.Exit(code)
}
f := PrettyErrorFormatter{w: os.Stderr, verbose: r.verbose}
if err != nil {
f.Format(err)
}
//nolint:revive,gocritic
os.Exit(code)
}
}
func (r *RootCmd) Command(subcommands []*serpent.Command) (*serpent.Command, error) {
if r.clock == nil {
r.clock = quartz.NewReal()
}
fmtLong := `Coder %s — A tool for provisioning self-hosted development environments with Terraform.
`
hiddenAgentAuth := &AgentAuth{}
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 gitAskpass(hiddenAgentAuth).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 aliases 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") {
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
}
if cmd.Name() == "agent-firewall" || cmd.Name() == "boundary" {
// The agent-firewall command (and its "boundary" alias) is
// integrated from the boundary package and has YAML-only
// options (e.g., allowlist from config file) that don't
// have flags or env vars.
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
}
})
// Add the PrintDeprecatedOptions and client session ID middleware to all
// commands. clientSessionIDMiddleware runs first so the resolved ID is on
// the invocation context for every downstream middleware and handler.
cmd.Walk(func(cmd *serpent.Command) {
if cmd.Middleware == nil {
cmd.Middleware = serpent.Chain(clientSessionIDMiddleware(), PrintDeprecatedOptions())
} else {
cmd.Middleware = serpent.Chain(clientSessionIDMiddleware(), cmd.Middleware, PrintDeprecatedOptions())
}
})
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: 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: varAllowRedirects,
Env: envAllowRedirects,
Description: "Follow HTTP redirects from the server instead of returning an error. Following redirects may alter the request method and/or drop its body.",
Value: serpent.BoolOf(&r.allowRedirects),
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. If a header value is a JWT, it will be refreshed based on the value in its `exp` field.",
Value: serpent.StringOf(&r.headerCommand),
Group: globalGroup,
},
{
Flag: varNoOpen,
Env: "CODER_NO_OPEN",
Description: "Suppress opening the browser when logging in, or starting the server.",
Value: serpent.BoolOf(&r.noOpen),
Hidden: true,
Group: globalGroup,
},
{
Flag: varForceTty,
Env: "CODER_FORCE_TTY",
Hidden: false,
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: varDisableNetworkTelemetry,
Env: "CODER_DISABLE_NETWORK_TELEMETRY",
Description: "Disable network telemetry. Network telemetry is collected when connecting to workspaces using the CLI, and is forwarded to the server. If telemetry is also enabled on the server, it may be sent to Coder. Network telemetry is used to measure network quality and detect regressions.",
Value: serpent.BoolOf(&r.disableNetworkTelemetry),
Group: globalGroup,
},
{
Flag: varClientTLSCAFile,
Env: envClientTLSCAFile,
Description: "Path to a CA certificate file to trust for API and DERP connections.",
Value: serpent.StringOf(&r.tlsCAFile),
Group: globalGroup,
},
{
Flag: varClientTLSCertFile,
Env: envClientTLSCertFile,
Description: "Path to a client certificate file for mTLS authentication with API and DERP. Requires --client-tls-key-file.",
Value: serpent.StringOf(&r.tlsClientCertFile),
Group: globalGroup,
},
{
Flag: varClientTLSKeyFile,
Env: envClientTLSKeyFile,
Description: "Path to a client private key file for mTLS authentication with API and DERP. Requires --client-tls-cert-file.",
Value: serpent.StringOf(&r.tlsClientKeyFile),
Group: globalGroup,
},
{
Flag: varUseKeyring,
Env: envUseKeyring,
Description: "Store and retrieve session tokens using the operating system " +
"keyring. This flag is ignored and file-based storage is used when " +
"--global-config is set or keyring usage is not supported on the current " +
"platform. Set to false to force file-based storage on supported platforms.",
Default: "true",
Value: serpent.BoolOf(&r.useKeyring),
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: "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,
},
}
hiddenAgentAuth.AttachOptions(cmd, true)
return cmd, nil
}
// RootCmd contains parameters and helpers useful to all commands.
type RootCmd struct {
clientURL *url.URL
token string
tokenBackend sessionstore.Backend
globalConfig string
header []string
headerCommand string
forceTTY bool
noOpen bool
verbose bool
versionFlag bool
disableDirect bool
debugHTTP bool
disableNetworkTelemetry bool
noVersionCheck bool
noFeatureWarning bool
allowRedirects bool
useKeyring bool
keyringServiceName string
useKeyringWithGlobalConfig bool
// clock is used for time-dependent operations. Initialized to
// quartz.NewReal() in Command() if unset.
clock quartz.Clock
// TLS configuration for custom CA or client certificates.
tlsCAFile string
tlsClientCertFile string
tlsClientKeyFile string
tlsConfig *tls.Config
}
// ensureClientURL loads the client URL from the config file if it
// wasn't provided via --url or CODER_URL.
func (r *RootCmd) ensureClientURL() error {
u, err := r.resolveClientURL()
if errors.Is(err, ErrClientURLNotConfigured) {
binPath, execErr := os.Executable()
if execErr != nil {
binPath = "coder"
}
return xerrors.Errorf(notLoggedInMessage, binPath)
}
if err != nil {
return err
}
r.clientURL = u
return nil
}
func (r *RootCmd) resolveClientURL() (*url.URL, error) {
if r.clientURL != nil && r.clientURL.String() != "" {
return r.clientURL, nil
}
rawURL, err := r.createConfig().URL().Read()
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return nil, ErrClientURLNotConfigured
}
return nil, xerrors.Errorf("read configured URL: %w", err)
}
parsedURL, err := url.Parse(strings.TrimSpace(rawURL))
if err != nil {
return nil, xerrors.Errorf("parse configured URL: %w", err)
}
return parsedURL, nil
}
// ResolveClientConnection resolves the deployment URL and client TLS transport
// without reading or requiring a user session.
func (r *RootCmd) ResolveClientConnection() (*url.URL, http.RoundTripper, error) {
serverURL, err := r.resolveClientURL()
if err != nil {
return nil, nil, err
}
if err := r.ensureTLSConfig(); err != nil {
return nil, nil, xerrors.Errorf("load client TLS config: %w", err)
}
transport, err := newHTTPTransport(r.tlsConfig)
if err != nil {
return nil, nil, xerrors.Errorf("create HTTP transport: %w", err)
}
return serverURL, transport, nil
}
// ensureTLSConfig loads the TLS configuration from files if specified.
// The resulting config is used for both API requests and DERP connections.
// If tlsConfig is already set programmatically, file-based configuration is skipped.
func (r *RootCmd) ensureTLSConfig() error {
// Already loaded or programmatically set - skip file loading
if r.tlsConfig != nil {
return nil
}
// No TLS config needed
if r.tlsCAFile == "" && r.tlsClientCertFile == "" && r.tlsClientKeyFile == "" {
return nil
}
// Validate that cert and key are specified together
if (r.tlsClientCertFile == "") != (r.tlsClientKeyFile == "") {
return xerrors.Errorf("--%s and --%s must be specified together", varClientTLSCertFile, varClientTLSKeyFile)
}
tlsConfig := &tls.Config{
MinVersion: tls.VersionTLS12,
}
// Load CA certificate if specified
if r.tlsCAFile != "" {
caData, err := os.ReadFile(r.tlsCAFile)
if err != nil {
return xerrors.Errorf("read TLS CA file %q: %w", r.tlsCAFile, err)
}
caPool := x509.NewCertPool()
if !caPool.AppendCertsFromPEM(caData) {
return xerrors.Errorf("failed to parse CA certificate in %q", r.tlsCAFile)
}
tlsConfig.RootCAs = caPool
}
// Load client certificate if specified
if r.tlsClientCertFile != "" && r.tlsClientKeyFile != "" {
cert, err := tls.LoadX509KeyPair(r.tlsClientCertFile, r.tlsClientKeyFile)
if err != nil {
return xerrors.Errorf("load TLS client certificate: %w", err)
}
tlsConfig.Certificates = []tls.Certificate{cert}
}
r.tlsConfig = tlsConfig
return nil
}
// InitClient creates and configures a new client with authentication, telemetry,
// and version checks.
func (r *RootCmd) InitClient(inv *serpent.Invocation) (*codersdk.Client, error) {
if err := r.ensureClientURL(); err != nil {
return nil, err
}
if r.token == "" {
tok, err := r.ensureTokenBackend().Read(r.clientURL)
// Even if there isn't a token, we don't care.
// Some API routes can be unauthenticated.
if err != nil && !xerrors.Is(err, os.ErrNotExist) {
if xerrors.Is(err, sessionstore.ErrNotImplemented) {
return nil, errKeyringNotSupported
}
return nil, err
}
if tok != "" {
r.token = tok
}
}
// Load TLS config from files if specified
if err := r.ensureTLSConfig(); err != nil {
return nil, err
}
// Configure HTTP client with transport wrappers
httpClient, err := r.createHTTPClient(inv.Context(), r.clientURL, inv)
if err != nil {
return nil, err
}
clientOpts := []codersdk.ClientOption{
codersdk.WithSessionToken(r.token),
codersdk.WithHTTPClient(httpClient),
}
if r.disableDirect {
clientOpts = append(clientOpts, codersdk.WithDisableDirectConnections())
}
if r.tlsConfig != nil {
clientOpts = append(clientOpts, codersdk.WithDERPTLSConfig(r.tlsConfig))
}
if r.debugHTTP {
clientOpts = append(clientOpts,
codersdk.WithPlainLogger(os.Stderr),
codersdk.WithLogBodies(),
)
}
return codersdk.New(r.clientURL, clientOpts...), nil
}
// TryInitClient is similar to InitClient but doesn't error when credentials are missing.
// This allows commands to run without requiring authentication, but still use auth if available.
func (r *RootCmd) TryInitClient(inv *serpent.Invocation) (*codersdk.Client, error) {
conf := r.createConfig()
// 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, just continue without URL
if err != nil {
// Continue with a nil or empty URL
if !os.IsNotExist(err) {
return nil, err
}
} else {
r.clientURL, err = url.Parse(strings.TrimSpace(rawURL))
if err != nil {
return nil, err
}
}
}
if r.token == "" {
tok, err := r.ensureTokenBackend().Read(r.clientURL)
// Even if there isn't a token, we don't care.
// Some API routes can be unauthenticated.
if err != nil && !xerrors.Is(err, os.ErrNotExist) {
if xerrors.Is(err, sessionstore.ErrNotImplemented) {
return nil, errKeyringNotSupported
}
return nil, err
}
if tok != "" {
r.token = tok
}
}
// Only configure the client if we have a URL
if r.clientURL != nil && r.clientURL.String() != "" {
// Load TLS config from files if specified
if err := r.ensureTLSConfig(); err != nil {
return nil, err
}
// Configure HTTP client with transport wrappers
httpClient, err := r.createHTTPClient(inv.Context(), r.clientURL, inv)
if err != nil {
return nil, err
}
clientOpts := []codersdk.ClientOption{
codersdk.WithSessionToken(r.token),
codersdk.WithHTTPClient(httpClient),
}
if r.disableDirect {
clientOpts = append(clientOpts, codersdk.WithDisableDirectConnections())
}
if r.tlsConfig != nil {
clientOpts = append(clientOpts, codersdk.WithDERPTLSConfig(r.tlsConfig))
}
if r.debugHTTP {
clientOpts = append(clientOpts,
codersdk.WithPlainLogger(os.Stderr),
codersdk.WithLogBodies(),
)
}
return codersdk.New(r.clientURL, clientOpts...), nil
}
// Return a minimal client if no URL is available
return &codersdk.Client{}, nil
}
// 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) {
return headerTransport(ctx, serverURL, r.header, r.headerCommand)
}
func (r *RootCmd) createHTTPClient(ctx context.Context, serverURL *url.URL, inv *serpent.Invocation) (*http.Client, error) {
baseTransport, err := newHTTPTransport(r.tlsConfig)
if err != nil {
return nil, err
}
transport := baseTransport
transport = wrapTransportWithTelemetryHeader(transport, inv)
transport = wrapTransportWithUserAgentHeader(transport, inv)
if sessionID := clientSessionIDFromContext(inv.Context()); sessionID != "" {
transport = wrapTransportWithSessionIDHeader(transport, sessionID)
}
if !r.noVersionCheck {
buildInfoTransport, err := newHTTPTransport(r.tlsConfig)
if err != nil {
return nil, err
}
transport = wrapTransportWithVersionCheck(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, codersdk.WithHTTPClient(&http.Client{Transport: buildInfoTransport}))
return basicClient.BuildInfo(ctx)
})
}
if !r.noFeatureWarning {
transport = wrapTransportWithEntitlementsCheck(transport, inv.Stderr)
}
headerTransport, err := r.HeaderTransport(ctx, serverURL)
if err != nil {
return nil, 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
httpClient := &http.Client{
Transport: headerTransport,
}
if !r.allowRedirects {
httpClient.CheckRedirect = rejectRedirect
}
return httpClient, nil
}
// rejectRedirect is an http.Client CheckRedirect hook. Following a redirect
// may alter the request method or drop its body, which silently changes the
// API call being made.
func rejectRedirect(req *http.Request, via []*http.Request) error {
err := &redirectError{to: req.URL}
if len(via) > 0 {
err.from = via[0].URL
}
return err
}
// redirectError is returned when the server redirects an API request.
type redirectError struct {
from *url.URL
to *url.URL
}
func (e *redirectError) Error() string {
if e.from == nil {
return fmt.Sprintf("server redirected request to %s", e.to)
}
return fmt.Sprintf("server redirected request from %s to %s", e.from, e.to)
}
// Helper returns a suggestion for resolving the redirect.
func (e *redirectError) Helper() string {
if e.to == nil {
return ""
}
newBase := &url.URL{Scheme: e.to.Scheme, Host: e.to.Host}
if e.from != nil && e.from.Scheme == newBase.Scheme && e.from.Host == newBase.Host {
return fmt.Sprintf("The request was redirected within the same deployment. Check for a proxy or path rewrite in front of Coder, or pass --%s to follow redirects.", varAllowRedirects)
}
return fmt.Sprintf("The deployment URL may have changed. Run %q to log in against the new URL, or pass --%s to follow redirects.", "coder login "+newBase.String(), varAllowRedirects)
}
func newHTTPTransport(tlsConfig *tls.Config) (http.RoundTripper, error) {
defaultTransport, ok := http.DefaultTransport.(*http.Transport)
if !ok {
if tlsConfig != nil {
return nil, xerrors.New("cannot apply TLS config: http.DefaultTransport is not *http.Transport")
}
return http.DefaultTransport, nil
}
// Clone http.DefaultTransport for each CLI client. Parallel tests and
// embedded callers may close idle connections on their own clients, and
// sharing the process-global transport can interrupt in-flight requests.
transport := defaultTransport.Clone()
if tlsConfig != nil {
transport.TLSClientConfig = tlsConfig
}
return transport, nil
}
func (r *RootCmd) createUnauthenticatedClient(ctx context.Context, serverURL *url.URL, inv *serpent.Invocation) (*codersdk.Client, error) {
// Load TLS config for login and other unauthenticated requests
if err := r.ensureTLSConfig(); err != nil {
return nil, err
}
httpClient, err := r.createHTTPClient(ctx, serverURL, inv)
if err != nil {
return nil, err
}
client := codersdk.New(serverURL, codersdk.WithHTTPClient(httpClient))
return client, nil
}
// ensureTokenBackend returns the session token storage backend, creating it if necessary.
// This must be called after flags are parsed so we can respect the value of the --use-keyring
// flag.
func (r *RootCmd) ensureTokenBackend() sessionstore.Backend {
if r.tokenBackend == nil {
// Checking for the --global-config directory being set is a bit wonky but necessary
// to allow extensions that invoke the CLI with this flag (e.g. VS code) to continue
// working without modification. In the future we should modify these extensions to
// either access the credential in the keyring (like Coder Desktop) or some other
// approach that doesn't rely on the session token being stored on disk.
assumeExtensionInUse := r.globalConfig != config.DefaultDir() && !r.useKeyringWithGlobalConfig
keyringSupported := runtime.GOOS == "windows" || runtime.GOOS == "darwin"
if r.useKeyring && !assumeExtensionInUse && keyringSupported {
serviceName := sessionstore.DefaultServiceName
if r.keyringServiceName != "" {
serviceName = r.keyringServiceName
}
r.tokenBackend = sessionstore.NewKeyringWithService(serviceName)
} else {
r.tokenBackend = sessionstore.NewFile(r.createConfig)
}
}
return r.tokenBackend
}
// WithKeyringServiceName sets a custom keyring service name for testing purposes.
// This allows tests to use isolated keyring storage while still exercising the
// genuine storage backend selection logic in ensureTokenBackend().
func (r *RootCmd) WithKeyringServiceName(serviceName string) {
r.keyringServiceName = serviceName
}
// UseKeyringWithGlobalConfig enables the use of the keyring storage backend
// when the --global-config directory is set. This is only intended as an override
// for tests, which require specifying the global config directory for test isolation.
func (r *RootCmd) UseKeyringWithGlobalConfig() {
r.useKeyringWithGlobalConfig = true
}
type AgentAuth struct {
// Agent Client config
agentToken string
agentTokenFile string
agentURL url.URL
agentAuth string
agentName string
}
func (a *AgentAuth) AttachOptions(cmd *serpent.Command, hidden bool) {
cmd.Options = append(cmd.Options, serpent.Option{
Name: "Agent Token",
Description: "An agent authentication token.",
Flag: "agent-token",
Env: envAgentToken,
Value: serpent.StringOf(&a.agentToken),
Hidden: hidden,
}, serpent.Option{
Name: "Agent Token File",
Description: "A file containing an agent authentication token.",
Flag: "agent-token-file",
Env: envAgentTokenFile,
Value: serpent.StringOf(&a.agentTokenFile),
Hidden: hidden,
}, serpent.Option{
Name: "Agent URL",
Description: "URL for an agent to access your deployment.",
Flag: "agent-url",
Env: envAgentURL,
Value: serpent.URLOf(&a.agentURL),
Hidden: hidden,
}, serpent.Option{
Name: "Agent Auth",
Description: "Specify the authentication type to use for the agent.",
Flag: "auth",
Env: envAgentAuth,
Default: "token",
Value: serpent.StringOf(&a.agentAuth),
Hidden: hidden,
}, serpent.Option{
Name: "Agent Name",
Description: "The name of the agent to authenticate as (only applicable for instance identity).",
Flag: "agent-name",
Env: envAgentName,
Value: serpent.StringOf(&a.agentName),
Hidden: hidden,
})
}
// CreateClient returns a new agent client from the command context. It works
// just like InitClient, but uses the agent token and URL instead.
func (a *AgentAuth) CreateClient() (*agentsdk.Client, error) {
agentURL := a.agentURL
if agentURL.String() == "" {
return nil, xerrors.Errorf("%s must be set", envAgentURL)
}
var iiOpts []agentsdk.InstanceIdentityOption
if a.agentName != "" {
iiOpts = append(iiOpts, agentsdk.WithInstanceIdentityAgentName(a.agentName))
}
switch a.agentAuth {
case "token":
token := a.agentToken
if token == "" {
if a.agentTokenFile == "" {
return nil, xerrors.Errorf("Either %s or %s must be set", envAgentToken, envAgentTokenFile)
}
tokenBytes, err := os.ReadFile(a.agentTokenFile)
if err != nil {
return nil, xerrors.Errorf("read token file %q: %w", a.agentTokenFile, err)
}
token = strings.TrimSpace(string(tokenBytes))
}
if token == "" {
return nil, xerrors.Errorf("CODER_AGENT_TOKEN or CODER_AGENT_TOKEN_FILE must be set for token auth")
}
return agentsdk.New(&a.agentURL, agentsdk.WithFixedToken(token)), nil
case "google-instance-identity":
return agentsdk.New(&a.agentURL, agentsdk.WithGoogleInstanceIdentity("", nil, iiOpts...)), nil
case "aws-instance-identity":
return agentsdk.New(&a.agentURL, agentsdk.WithAWSInstanceIdentity(iiOpts...)), nil
case "azure-instance-identity":
return agentsdk.New(&a.agentURL, agentsdk.WithAzureInstanceIdentity(iiOpts...)), nil
default:
return nil, xerrors.Errorf("unknown agent auth type: %s", a.agentAuth)
}
}
type OrganizationContext struct {
// FlagSelect is the value passed in via the --org flag
FlagSelect string
}
func NewOrganizationContext() *OrganizationContext {
return &OrganizationContext{}
}
func (*OrganizationContext) optionName() string { return "Organization" }
func (o *OrganizationContext) AttachOptions(cmd *serpent.Command) {
cmd.Options = append(cmd.Options, serpent.Option{
Name: o.optionName(),
Description: "Select which organization (uuid or name) to use.",
// Only required if the user is a part of more than 1 organization.
// Otherwise, we can assume a default value.
Required: false,
Flag: "org",
FlagShorthand: "O",
Env: "CODER_ORGANIZATION",
Value: serpent.StringOf(&o.FlagSelect),
})
}
func (o *OrganizationContext) ValueSource(inv *serpent.Invocation) (string, serpent.ValueSource) {
opt := inv.Command.Options.ByName(o.optionName())
if opt == nil {
return o.FlagSelect, serpent.ValueSourceNone
}
return o.FlagSelect, opt.ValueSource
}
func (o *OrganizationContext) Selected(inv *serpent.Invocation, client *codersdk.Client) (codersdk.Organization, error) {
// Fetch the set of organizations the user is a member of.
orgs, err := client.OrganizationsByUser(inv.Context(), codersdk.Me)
if err != nil {
return codersdk.Organization{}, xerrors.Errorf("get organizations: %w", err)
}
// User manually selected an organization
if o.FlagSelect != "" {
index := slices.IndexFunc(orgs, func(org codersdk.Organization) bool {
return org.Name == o.FlagSelect || org.ID.String() == o.FlagSelect
})
if index >= 0 {
return orgs[index], nil
}
// Not in membership list - try direct fetch.
// This allows site-wide admins (e.g., Owners) to use orgs they aren't
// members of.
org, err := client.OrganizationByName(inv.Context(), o.FlagSelect)
if err != nil {
var names []string
for _, org := range orgs {
names = append(names, org.Name)
}
var sdkErr *codersdk.Error
if errors.As(err, &sdkErr) && sdkErr.StatusCode() == http.StatusNotFound {
return codersdk.Organization{}, xerrors.Errorf("organization %q not found, are you sure you are a member of this organization? "+
"Valid options for '--org=' are [%s].", o.FlagSelect, strings.Join(names, ", "))
}
return codersdk.Organization{}, xerrors.Errorf("get organization %q: %w", o.FlagSelect, err)
}
return org, nil
}
if len(orgs) == 1 {
return orgs[0], nil
}
// No org selected, and we are more than 1? Return an error.
validOrgs := make([]string, 0, len(orgs))
for _, org := range orgs {
validOrgs = append(validOrgs, org.Name)
}
return codersdk.Organization{}, xerrors.Errorf("Must select an organization with --org=<org_name>. Choose from: %s", strings.Join(validOrgs, ", "))
}
func initAppearance(ctx context.Context, client *codersdk.Client) codersdk.AppearanceConfig {
// best effort
cfg, _ := client.Appearance(ctx)
if cfg.DocsURL == "" {
cfg.DocsURL = codersdk.DefaultDocsURL()
}
return cfg
}
// createConfig consumes the global configuration flag to produce a config root.
func (r *RootCmd) createConfig() config.Root {
return config.Root(r.globalConfig)
}
// isTTYIn returns whether the passed invocation is having stdin read from a TTY
func isTTYIn(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 invocation is having stdout written to a TTY
func isTTYOut(inv *serpent.Invocation) bool {
return isTTYWriter(inv, inv.Stdout)
}
// isTTYErr returns whether the passed invocation is having stderr written to a TTY
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,gocritic
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
}
// ExitCode returns the OS exit code that the CLI will use when this error is
// returned from a command handler.
func (e *exitError) ExitCode() int {
return e.code
}
// 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 redirectErr, ok := err.(*redirectError); ok {
return formatRedirectError(from, redirectErr), 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
}
if uw, ok := err.(interface{ Unwrap() error }); ok {
if unwrapped := uw.Unwrap(); unwrapped != nil {
msg, special := cliHumanFormatError(from+traceError(err), unwrapped, 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 += 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()
}
// formatRedirectError formats a redirectError for CLI output.
func formatRedirectError(from string, err *redirectError) string {
var str strings.Builder
if from != "" {
_, _ = str.WriteString(pretty.Sprint(headLineStyle(), fmt.Sprintf("Trace=[%s]", from)))
_, _ = str.WriteString("\n")
}
_, _ = str.WriteString(pretty.Sprint(headLineStyle(), err.Error()))
if helper := err.Helper(); helper != "" {
_, _ = str.WriteString("\n")
_, _ = str.WriteString(pretty.Sprintf(tailLineStyle(), "Suggestion: %s", helper))
}
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 {
var a, b string
if err != nil {
a = err.Error()
}
if uw != nil {
uwerr := uw.Unwrap()
if uwerr != nil {
b = uwerr.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}
}
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 -fsSL https://coder.com/install.sh | sh -s -- --version %s'", version)
}
// serverVersionMessage returns a warning message if the server version
// is a release candidate or development build. Returns empty string
// for stable versions. RC is checked before devel because RC dev
// builds (e.g. v2.33.0-rc.1-devel+hash) contain both tags.
func serverVersionMessage(serverVersion string) string {
switch {
case buildinfo.IsRCVersion(serverVersion):
return fmt.Sprintf("the server is running a release candidate of Coder (%s)", serverVersion)
case buildinfo.IsDevVersion(serverVersion):
return fmt.Sprintf("the server is running a development version of Coder (%s)", serverVersion)
default:
return ""
}
}
// 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
})
}
// wrapTransportWithVersionCheck adds a middleware to the HTTP transport
// that checks the server version and warns about development builds,
// release candidates, and client/server version mismatches.
func wrapTransportWithVersionCheck(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
}
// Warn about non-stable server versions. Skip
// during tests to avoid polluting golden files.
if msg := serverVersionMessage(serverVersion); msg != "" && flag.Lookup("test.v") == nil {
warning := pretty.Sprint(cliui.DefaultStyles.Warn, msg)
_, _ = fmt.Fprintln(inv.Stderr, warning)
}
if buildinfo.VersionsMatch(clientVersion, serverVersion) {
return
}
upgradeMessage := defaultUpgradeMessage(semver.Canonical(serverVersion))
if serverInfo, err := getBuildInfo(inv.Context()); err == nil {
switch {
case serverInfo.UpgradeMessage != "":
upgradeMessage = serverInfo.UpgradeMessage
// The site-local `install.sh` was introduced in v2.19.0
case serverInfo.DashboardURL != "" && semver.Compare(semver.MajorMinor(serverVersion), "v2.19") >= 0:
upgradeMessage = fmt.Sprintf("download %s with: 'curl -fsSL %s/install.sh | sh'", serverVersion, serverInfo.DashboardURL)
}
}
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)
})
}
// wrapTransportWithUserAgentHeader sets a User-Agent header for all CLI requests
// that includes the CLI version, os/arch, and the specific command being run.
func wrapTransportWithUserAgentHeader(transport http.RoundTripper, inv *serpent.Invocation) http.RoundTripper {
var (
userAgent string
once sync.Once
)
return roundTripper(func(req *http.Request) (*http.Response, error) {
once.Do(func() {
userAgent = fmt.Sprintf("coder-cli/%s (%s/%s; %s)", buildinfo.Version(), runtime.GOOS, runtime.GOARCH, inv.Command.FullName())
})
req.Header.Set("User-Agent", userAgent)
return transport.RoundTrip(req)
})
}
// clientSessionIDEnv is the environment variable a spawning client (Toolbox,
// the VS Code plugin) can set so the CLI it launches reuses an existing client
// session ID instead of generating a new one.
const clientSessionIDEnv = "CODER_TRACE_SESSION_ID"
// annotationClientSessionID marks commands that establish a client session and
// should resolve a client_session_id. clientSessionIDMiddleware only resolves
// and attaches the ID for commands that opt in with this annotation, so
// long-running daemon commands (server, agent, provisionerd, and so on) never
// carry a meaningless session ID in their logs, request baggage, or telemetry.
const annotationClientSessionID = "client_session_id"
type clientSessionIDContextKey struct{}
// withClientSessionID returns a copy of ctx carrying the client session ID so
// non-log consumers (HTTP baggage, tailnet telemetry) can read it back.
func withClientSessionID(ctx context.Context, id string) context.Context {
return context.WithValue(ctx, clientSessionIDContextKey{}, id)
}
// clientSessionIDFromContext returns the client session ID stored on ctx, or
// the empty string if none was resolved for this invocation.
func clientSessionIDFromContext(ctx context.Context) string {
id, _ := ctx.Value(clientSessionIDContextKey{}).(string)
return id
}
// resolveClientSessionID returns the client session ID for this invocation.
// When CODER_TRACE_SESSION_ID is set it is used verbatim so a spawning client
// can correlate the CLI it launches. A warning is logged if the value is not
// the canonical 32-character lowercase hex form, since coderd and agent
// middleware drop non-canonical values. Otherwise a new session ID is
// generated.
func resolveClientSessionID(inv *serpent.Invocation) (string, error) {
if id, ok := inv.Environ.Lookup(clientSessionIDEnv); ok && id != "" {
if !tracing.ValidSessionID(id) {
cliui.Warnf(inv.Stderr,
"%s is not a 32-character lowercase hexadecimal string; it will not correlate in coderd and agent logs.",
clientSessionIDEnv)
}
return id, nil
}
id, err := tracing.NewSessionID()
if err != nil {
return "", xerrors.Errorf("generate client session ID: %w", err)
}
return id, nil
}
// clientSessionIDMiddleware resolves a single client session ID per invocation
// and stores it on the invocation context for commands that opt in with the
// annotationClientSessionID annotation. It attaches the ID as a slog field so
// any log written with the invocation context (or a descendant) carries
// client_session_id regardless of which logger emits it, and stores the raw ID
// so createHTTPClient can attach it as W3C baggage and ssh can forward it as
// tailnet telemetry. Commands that do not opt in (and completion mode) are
// skipped so daemon logs stay free of an irrelevant session ID.
func clientSessionIDMiddleware() serpent.MiddlewareFunc {
return func(next serpent.HandlerFunc) serpent.HandlerFunc {
return func(inv *serpent.Invocation) error {
if inv.IsCompletionMode() || inv.Command == nil ||
!inv.Command.Annotations.IsSet(annotationClientSessionID) {
return next(inv)
}
id, err := resolveClientSessionID(inv)
if err != nil {
return err
}
ctx := slog.With(inv.Context(), slog.F("client_session_id", id))
ctx = withClientSessionID(ctx, id)
return next(inv.WithContext(ctx))
}
}
}
// wrapTransportWithSessionIDHeader attaches the client session ID to every
// request as W3C baggage under the client_session_id key, so coderd and agent
// middleware can correlate logs, spans, and telemetry by session. It is set
// regardless of whether tracing is enabled, and merges with any baggage
// already present on the request rather than overwriting it.
func wrapTransportWithSessionIDHeader(transport http.RoundTripper, sessionID string) http.RoundTripper {
member, err := baggage.NewMemberRaw(tracing.SessionIDBaggageKey, sessionID)
if err != nil {
// An invalid session ID should never reach here. If it somehow does,
// skip attaching baggage rather than failing every request.
return transport
}
return roundTripper(func(req *http.Request) (*http.Response, error) {
ctx := propagation.Baggage{}.Extract(req.Context(), propagation.HeaderCarrier(req.Header))
if bag, err := baggage.FromContext(ctx).SetMember(member); err == nil {
ctx = baggage.ContextWithBaggage(ctx, bag)
propagation.Baggage{}.Inject(ctx, propagation.HeaderCarrier(req.Header))
}
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)
}
// PrintDeprecatedOptions loops through all command options, and
// prints a warning for usage of deprecated options.
func PrintDeprecatedOptions() serpent.MiddlewareFunc {
return func(next serpent.HandlerFunc) serpent.HandlerFunc {
return func(inv *serpent.Invocation) error {
opts := inv.Command.Options
// Print deprecation warnings.
for _, opt := range opts {
if opt.UseInstead == nil {
continue
}
if opt.ValueSource == serpent.ValueSourceNone || opt.ValueSource == serpent.ValueSourceDefault {
continue
}
// Verify that this deprecated option was itself
// the source of the value. Serpent propagates
// ValueSource across all options that share the
// same Value pointer, so a new option being set
// can make a deprecated sibling appear set when
// it was not.
source := deprecatedOptionDirectSource(inv, opt)
if source == serpent.ValueSourceNone {
continue
}
var warnStr strings.Builder
_, _ = warnStr.WriteString(translateSource(source, opt))
_, _ = warnStr.WriteString(" is deprecated, please use ")
for i, use := range opt.UseInstead {
_, _ = warnStr.WriteString(translateSource(source, use))
if i != len(opt.UseInstead)-1 {
_, _ = warnStr.WriteString(" and ")
}
}
_, _ = warnStr.WriteString(" instead.\n")
cliui.Warn(inv.Stderr,
warnStr.String(),
)
}
return next(inv)
}
}
}
// deprecatedOptionDirectSource returns the source by which a deprecated
// option was directly set, ignoring any propagated ValueSource from
// sibling options that share the same Value pointer.
func deprecatedOptionDirectSource(inv *serpent.Invocation, opt serpent.Option) serpent.ValueSource {
if opt.Flag != "" {
fl := inv.ParsedFlags().Lookup(opt.Flag)
if fl != nil && fl.Changed {
return serpent.ValueSourceFlag
}
}
if opt.Env != "" {
_, exists := inv.Environ.Lookup(opt.Env)
if exists {
return serpent.ValueSourceEnv
}
}
if opt.ValueSource == serpent.ValueSourceYAML {
// There is no straightforward way to check whether a
// specific YAML key was present in the config file, so
// we conservatively assume the deprecated key was used.
return serpent.ValueSourceYAML
}
return serpent.ValueSourceNone
}
// translateSource provides the name of the source of the option, depending on the
// supplied target ValueSource.
func translateSource(target serpent.ValueSource, opt serpent.Option) string {
switch target {
case serpent.ValueSourceFlag:
return fmt.Sprintf("`--%s`", opt.Flag)
case serpent.ValueSourceEnv:
return fmt.Sprintf("`%s`", opt.Env)
case serpent.ValueSourceYAML:
return fmt.Sprintf("`%s`", fullYamlName(opt))
default:
return opt.Name
}
}
func fullYamlName(opt serpent.Option) string {
var full strings.Builder
for _, name := range opt.Group.Ancestry() {
_, _ = full.WriteString(name.YAML)
_, _ = full.WriteString(".")
}
_, _ = full.WriteString(opt.YAML)
return full.String()
}