mirror of https://github.com/coder/coder.git
1259 lines
37 KiB
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)
|
|
}
|