diff --git a/api/api.go b/api/api.go index 00844a422..ad69f23fc 100644 --- a/api/api.go +++ b/api/api.go @@ -18,6 +18,8 @@ import ( "net/http" "strconv" "strings" + + "github.com/opentracing/opentracing-go" ) const githubAPI = "https://api.github.com" @@ -42,7 +44,7 @@ func (a *API) GetUser(ctx context.Context) (*User, error) { } a.setHeaders(req) - resp, err := a.client.Do(req) + resp, err := a.do(ctx, req, "/user") if err != nil { return nil, fmt.Errorf("error making request: %v", err) } @@ -87,7 +89,7 @@ func (a *API) GetRepository(ctx context.Context, nwo string) (*Repository, error } a.setHeaders(req) - resp, err := a.client.Do(req) + resp, err := a.do(ctx, req, "/repos/*") if err != nil { return nil, fmt.Errorf("error making request: %v", err) } @@ -156,7 +158,7 @@ func (a *API) ListCodespaces(ctx context.Context, user *User) ([]*Codespace, err } a.setHeaders(req) - resp, err := a.client.Do(req) + resp, err := a.do(ctx, req, "/vscs_internal/user/*/codespaces") if err != nil { return nil, fmt.Errorf("error making request: %v", err) } @@ -204,7 +206,7 @@ func (a *API) GetCodespaceToken(ctx context.Context, ownerLogin, codespaceName s } a.setHeaders(req) - resp, err := a.client.Do(req) + resp, err := a.do(ctx, req, "/vscs_internal/user/*/codespaces/*/token") if err != nil { return "", fmt.Errorf("error making request: %v", err) } @@ -238,7 +240,7 @@ func (a *API) GetCodespace(ctx context.Context, token, owner, codespace string) } req.Header.Set("Authorization", "Bearer "+token) - resp, err := a.client.Do(req) + resp, err := a.do(ctx, req, "/vscs_internal/user/*/codespaces/*") if err != nil { return nil, fmt.Errorf("error making request: %v", err) } @@ -272,7 +274,7 @@ func (a *API) StartCodespace(ctx context.Context, token string, codespace *Codes } req.Header.Set("Authorization", "Bearer "+token) - resp, err := a.client.Do(req) + resp, err := a.do(ctx, req, "/vscs_internal/proxy/environments/*/start") if err != nil { return fmt.Errorf("error making request: %v", err) } @@ -309,7 +311,7 @@ func (a *API) GetCodespaceRegionLocation(ctx context.Context) (string, error) { return "", fmt.Errorf("error creating request: %v", err) } - resp, err := a.client.Do(req) + resp, err := a.do(ctx, req, req.URL.String()) if err != nil { return "", fmt.Errorf("error making request: %v", err) } @@ -350,7 +352,7 @@ func (a *API) GetCodespacesSKUs(ctx context.Context, user *User, repository *Rep req.URL.RawQuery = q.Encode() a.setHeaders(req) - resp, err := a.client.Do(req) + resp, err := a.do(ctx, req, "/vscs_internal/user/*/skus") if err != nil { return nil, fmt.Errorf("error making request: %v", err) } @@ -394,7 +396,7 @@ func (a *API) CreateCodespace(ctx context.Context, user *User, repository *Repos } a.setHeaders(req) - resp, err := a.client.Do(req) + resp, err := a.do(ctx, req, "/vscs_internal/user/*/codespaces") if err != nil { return nil, fmt.Errorf("error making request: %v", err) } @@ -424,7 +426,7 @@ func (a *API) DeleteCodespace(ctx context.Context, user *User, token, codespaceN } req.Header.Set("Authorization", "Bearer "+token) - resp, err := a.client.Do(req) + resp, err := a.do(ctx, req, "/vscs_internal/user/*/codespaces/*") if err != nil { return fmt.Errorf("error making request: %v", err) } @@ -456,7 +458,7 @@ func (a *API) GetCodespaceRepositoryContents(ctx context.Context, codespace *Cod req.URL.RawQuery = q.Encode() a.setHeaders(req) - resp, err := a.client.Do(req) + resp, err := a.do(ctx, req, "/repos/*/contents/*") if err != nil { return nil, fmt.Errorf("error making request: %v", err) } @@ -488,6 +490,14 @@ func (a *API) GetCodespaceRepositoryContents(ctx context.Context, codespace *Cod return decoded, nil } +func (a *API) do(ctx context.Context, req *http.Request, spanName string) (*http.Response, error) { + // TODO(adonovan): use NewRequestWithContext(ctx) and drop ctx parameter. + span, ctx := opentracing.StartSpanFromContext(ctx, spanName) + defer span.Finish() + req = req.WithContext(ctx) + return a.client.Do(req) +} + func (a *API) setHeaders(req *http.Request) { req.Header.Set("Authorization", "Bearer "+a.token) req.Header.Set("Accept", "application/vnd.github.v3+json") diff --git a/cmd/ghcs/code.go b/cmd/ghcs/code.go index d14a66926..d34b75ed8 100644 --- a/cmd/ghcs/code.go +++ b/cmd/ghcs/code.go @@ -8,7 +8,6 @@ import ( "github.com/github/ghcs/api" "github.com/github/ghcs/cmd/ghcs/output" - "github.com/github/ghcs/internal/codespaces" "github.com/skratchdot/open-golang/open" "github.com/spf13/cobra" ) @@ -54,9 +53,9 @@ func code(codespaceName string, useInsiders bool) error { } if codespaceName == "" { - codespace, err := codespaces.ChooseCodespace(ctx, apiClient, user) + codespace, err := chooseCodespace(ctx, apiClient, user) if err != nil { - if err == codespaces.ErrNoCodespaces { + if err == errNoCodespaces { return err } return fmt.Errorf("error choosing codespace: %v", err) diff --git a/cmd/ghcs/common.go b/cmd/ghcs/common.go new file mode 100644 index 000000000..f53541319 --- /dev/null +++ b/cmd/ghcs/common.go @@ -0,0 +1,103 @@ +package main + +// This file defines functions common to the entire ghcs command set. + +import ( + "context" + "errors" + "fmt" + "sort" + + "github.com/AlecAivazis/survey/v2" + "github.com/github/ghcs/api" + "golang.org/x/term" +) + +var errNoCodespaces = errors.New("You have no codespaces.") + +func chooseCodespace(ctx context.Context, apiClient *api.API, user *api.User) (*api.Codespace, error) { + codespaces, err := apiClient.ListCodespaces(ctx, user) + if err != nil { + return nil, fmt.Errorf("error getting codespaces: %v", err) + } + + if len(codespaces) == 0 { + return nil, errNoCodespaces + } + + sort.Slice(codespaces, func(i, j int) bool { + return codespaces[i].CreatedAt > codespaces[j].CreatedAt + }) + + codespacesByName := make(map[string]*api.Codespace) + codespacesNames := make([]string, 0, len(codespaces)) + for _, codespace := range codespaces { + codespacesByName[codespace.Name] = codespace + codespacesNames = append(codespacesNames, codespace.Name) + } + + sshSurvey := []*survey.Question{ + { + Name: "codespace", + Prompt: &survey.Select{ + Message: "Choose codespace:", + Options: codespacesNames, + Default: codespacesNames[0], + }, + Validate: survey.Required, + }, + } + + var answers struct { + Codespace string + } + if err := ask(sshSurvey, &answers); err != nil { + return nil, fmt.Errorf("error getting answers: %v", err) + } + + codespace := codespacesByName[answers.Codespace] + return codespace, nil +} + +// getOrChooseCodespace prompts the user to choose a codespace if the codespaceName is empty. +// It then fetches the codespace token and the codespace record. +func getOrChooseCodespace(ctx context.Context, apiClient *api.API, user *api.User, codespaceName string) (codespace *api.Codespace, token string, err error) { + if codespaceName == "" { + codespace, err = chooseCodespace(ctx, apiClient, user) + if err != nil { + if err == errNoCodespaces { + return nil, "", err + } + return nil, "", fmt.Errorf("choosing codespace: %v", err) + } + codespaceName = codespace.Name + + token, err = apiClient.GetCodespaceToken(ctx, user.Login, codespaceName) + if err != nil { + return nil, "", fmt.Errorf("getting codespace token: %v", err) + } + } else { + token, err = apiClient.GetCodespaceToken(ctx, user.Login, codespaceName) + if err != nil { + return nil, "", fmt.Errorf("getting codespace token for given codespace: %v", err) + } + + codespace, err = apiClient.GetCodespace(ctx, token, user.Login, codespaceName) + if err != nil { + return nil, "", fmt.Errorf("getting full codespace details: %v", err) + } + } + + return codespace, token, nil +} + +var hasTTY = term.IsTerminal(0) && term.IsTerminal(1) // is process connected to a terminal? + +// ask asks survey questions on the terminal, using standard options. +// It fails unless hasTTY, but ideally callers should avoid calling it in that case. +func ask(qs []*survey.Question, response interface{}) error { + if !hasTTY { + return fmt.Errorf("no terminal") + } + return survey.Ask(qs, response, survey.WithShowCursor(true)) +} diff --git a/cmd/ghcs/create.go b/cmd/ghcs/create.go index bd1d89e4e..093450e7d 100644 --- a/cmd/ghcs/create.go +++ b/cmd/ghcs/create.go @@ -286,8 +286,3 @@ func getMachineName(ctx context.Context, machine string, user *api.User, repo *a return machine, nil } - -// ask asks survery questions using standard options. -func ask(qs []*survey.Question, response interface{}) error { - return survey.Ask(qs, response, survey.WithShowCursor(true)) -} diff --git a/cmd/ghcs/delete.go b/cmd/ghcs/delete.go index 92c405766..75b9362bb 100644 --- a/cmd/ghcs/delete.go +++ b/cmd/ghcs/delete.go @@ -8,7 +8,6 @@ import ( "github.com/github/ghcs/api" "github.com/github/ghcs/cmd/ghcs/output" - "github.com/github/ghcs/internal/codespaces" "github.com/spf13/cobra" ) @@ -63,7 +62,7 @@ func delete_(codespaceName string) error { return fmt.Errorf("error getting user: %v", err) } - codespace, token, err := codespaces.GetOrChooseCodespace(ctx, apiClient, user, codespaceName) + codespace, token, err := getOrChooseCodespace(ctx, apiClient, user, codespaceName) if err != nil { return fmt.Errorf("get or choose codespace: %v", err) } diff --git a/cmd/ghcs/logs.go b/cmd/ghcs/logs.go index 4c880725d..cb6ba19d2 100644 --- a/cmd/ghcs/logs.go +++ b/cmd/ghcs/logs.go @@ -62,7 +62,7 @@ func logs(ctx context.Context, log *output.Logger, codespaceName string, follow return fmt.Errorf("getting user: %v", err) } - codespace, token, err := codespaces.GetOrChooseCodespace(ctx, apiClient, user, codespaceName) + codespace, token, err := getOrChooseCodespace(ctx, apiClient, user, codespaceName) if err != nil { return fmt.Errorf("get or choose codespace: %v", err) } diff --git a/cmd/ghcs/main.go b/cmd/ghcs/main.go index 3e861dacb..3a692d443 100644 --- a/cmd/ghcs/main.go +++ b/cmd/ghcs/main.go @@ -4,8 +4,13 @@ import ( "errors" "fmt" "io" + "log" "os" + "strconv" + "strings" + "github.com/lightstep/lightstep-tracer-go" + "github.com/opentracing/opentracing-go" "github.com/spf13/cobra" ) @@ -18,22 +23,32 @@ func main() { var version = "DEV" -var rootCmd = &cobra.Command{ - Use: "ghcs", - SilenceUsage: true, // don't print usage message after each error (see #80) - SilenceErrors: false, // print errors automatically so that main need not - Long: `Unofficial CLI tool to manage GitHub Codespaces. +var rootCmd = newRootCmd() + +func newRootCmd() *cobra.Command { + var lightstep string + + root := &cobra.Command{ + Use: "ghcs", + SilenceUsage: true, // don't print usage message after each error (see #80) + SilenceErrors: false, // print errors automatically so that main need not + Long: `Unofficial CLI tool to manage GitHub Codespaces. Running commands requires the GITHUB_TOKEN environment variable to be set to a token to access the GitHub API with.`, - Version: version, + Version: version, - PersistentPreRunE: func(cmd *cobra.Command, args []string) error { - if os.Getenv("GITHUB_TOKEN") == "" { - return tokenError - } - return nil - }, + PersistentPreRunE: func(cmd *cobra.Command, args []string) error { + if os.Getenv("GITHUB_TOKEN") == "" { + return tokenError + } + return initLightstep(lightstep) + }, + } + + root.PersistentFlags().StringVar(&lightstep, "lightstep", "", "Lightstep tracing endpoint (service:token@host:port)") + + return root } var tokenError = errors.New("GITHUB_TOKEN is missing") @@ -45,3 +60,51 @@ func explainError(w io.Writer, err error) { return } } + +// initLightstep parses the --lightstep=service:token@host:port flag and +// enables tracing if non-empty. +func initLightstep(config string) error { + if config == "" { + return nil + } + + cut := func(s, sep string) (pre, post string) { + if i := strings.Index(s, sep); i >= 0 { + return s[:i], s[i+len(sep):] + } + return s, "" + } + + // Parse service:token@host:port. + serviceToken, hostPort := cut(config, "@") + service, token := cut(serviceToken, ":") + host, port := cut(hostPort, ":") + portI, err := strconv.Atoi(port) + if err != nil { + return fmt.Errorf("invalid Lightstep configuration: %s", config) + } + + opentracing.SetGlobalTracer(lightstep.NewTracer(lightstep.Options{ + AccessToken: token, + Collector: lightstep.Endpoint{ + Host: host, + Port: portI, + Plaintext: false, + }, + Tags: opentracing.Tags{ + lightstep.ComponentNameKey: service, + }, + })) + + // Report failure to record traces. + lightstep.SetGlobalEventHandler(func(ev lightstep.Event) { + switch ev := ev.(type) { + case lightstep.EventStatusReport, lightstep.MetricEventStatusReport: + // ignore + default: + log.Printf("[trace] %s", ev) + } + }) + + return nil +} diff --git a/cmd/ghcs/ports.go b/cmd/ghcs/ports.go index 2bb3e6917..8b73626fa 100644 --- a/cmd/ghcs/ports.go +++ b/cmd/ghcs/ports.go @@ -61,9 +61,9 @@ func ports(codespaceName string, asJSON bool) error { return fmt.Errorf("error getting user: %v", err) } - codespace, token, err := codespaces.GetOrChooseCodespace(ctx, apiClient, user, codespaceName) + codespace, token, err := getOrChooseCodespace(ctx, apiClient, user, codespaceName) if err != nil { - if err == codespaces.ErrNoCodespaces { + if err == errNoCodespaces { return err } return fmt.Errorf("error choosing codespace: %v", err) @@ -157,7 +157,7 @@ func getDevContainer(ctx context.Context, apiClient *api.API, codespace *api.Cod // newPortsPublicCmd returns a Cobra "ports public" subcommand, which makes a given port public. func newPortsPublicCmd() *cobra.Command { - newPortsPublicCmd := &cobra.Command{ + return &cobra.Command{ Use: "public ", Short: "Mark port as public", Args: cobra.MinimumNArgs(1), @@ -181,13 +181,11 @@ func newPortsPublicCmd() *cobra.Command { return updatePortVisibility(log, codespace, port, true) }, } - - return newPortsPublicCmd } // newPortsPrivateCmd returns a Cobra "ports private" subcommand, which makes a given port private. func newPortsPrivateCmd() *cobra.Command { - newPortsPrivateCmd := &cobra.Command{ + return &cobra.Command{ Use: "private ", Short: "Mark port as private", Args: cobra.MinimumNArgs(1), @@ -211,8 +209,6 @@ func newPortsPrivateCmd() *cobra.Command { return updatePortVisibility(log, codespace, port, false) }, } - - return newPortsPrivateCmd } func updatePortVisibility(log *output.Logger, codespaceName, sourcePort string, public bool) error { @@ -224,9 +220,9 @@ func updatePortVisibility(log *output.Logger, codespaceName, sourcePort string, return fmt.Errorf("error getting user: %v", err) } - codespace, token, err := codespaces.GetOrChooseCodespace(ctx, apiClient, user, codespaceName) + codespace, token, err := getOrChooseCodespace(ctx, apiClient, user, codespaceName) if err != nil { - if err == codespaces.ErrNoCodespaces { + if err == errNoCodespaces { return err } return fmt.Errorf("error getting codespace: %v", err) @@ -258,7 +254,7 @@ func updatePortVisibility(log *output.Logger, codespaceName, sourcePort string, // NewPortsForwardCmd returns a Cobra "ports forward" subcommand, which forwards a set of // port pairs from the codespace to localhost. func newPortsForwardCmd() *cobra.Command { - newPortsForwardCmd := &cobra.Command{ + return &cobra.Command{ Use: "forward :...", Short: "Forward ports", Args: cobra.MinimumNArgs(1), @@ -284,8 +280,6 @@ func newPortsForwardCmd() *cobra.Command { return forwardPorts(log, codespace, ports) }, } - - return newPortsForwardCmd } func forwardPorts(log *output.Logger, codespaceName string, ports []string) error { @@ -302,9 +296,9 @@ func forwardPorts(log *output.Logger, codespaceName string, ports []string) erro return fmt.Errorf("error getting user: %v", err) } - codespace, token, err := codespaces.GetOrChooseCodespace(ctx, apiClient, user, codespaceName) + codespace, token, err := getOrChooseCodespace(ctx, apiClient, user, codespaceName) if err != nil { - if err == codespaces.ErrNoCodespaces { + if err == errNoCodespaces { return err } return fmt.Errorf("error getting codespace: %v", err) diff --git a/cmd/ghcs/ssh.go b/cmd/ghcs/ssh.go index e2003b347..aefb959f3 100644 --- a/cmd/ghcs/ssh.go +++ b/cmd/ghcs/ssh.go @@ -52,7 +52,7 @@ func ssh(ctx context.Context, sshProfile, codespaceName string, localSSHServerPo return fmt.Errorf("error getting user: %v", err) } - codespace, token, err := codespaces.GetOrChooseCodespace(ctx, apiClient, user, codespaceName) + codespace, token, err := getOrChooseCodespace(ctx, apiClient, user, codespaceName) if err != nil { return fmt.Errorf("get or choose codespace: %v", err) } diff --git a/internal/codespaces/codespaces.go b/internal/codespaces/codespaces.go index fd04b303e..9aee3564c 100644 --- a/internal/codespaces/codespaces.go +++ b/internal/codespaces/codespaces.go @@ -4,62 +4,12 @@ import ( "context" "errors" "fmt" - "sort" "time" - "github.com/AlecAivazis/survey/v2" "github.com/github/ghcs/api" "github.com/github/go-liveshare" ) -var ( - ErrNoCodespaces = errors.New("You have no codespaces.") -) - -func ChooseCodespace(ctx context.Context, apiClient *api.API, user *api.User) (*api.Codespace, error) { - codespaces, err := apiClient.ListCodespaces(ctx, user) - if err != nil { - return nil, fmt.Errorf("error getting codespaces: %v", err) - } - - if len(codespaces) == 0 { - return nil, ErrNoCodespaces - } - - sort.Slice(codespaces, func(i, j int) bool { - return codespaces[i].CreatedAt > codespaces[j].CreatedAt - }) - - codespacesByName := make(map[string]*api.Codespace) - codespacesNames := make([]string, 0, len(codespaces)) - for _, codespace := range codespaces { - codespacesByName[codespace.Name] = codespace - codespacesNames = append(codespacesNames, codespace.Name) - } - - sshSurvey := []*survey.Question{ - { - Name: "codespace", - Prompt: &survey.Select{ - Message: "Choose codespace:", - Options: codespacesNames, - Default: codespacesNames[0], - }, - Validate: survey.Required, - }, - } - - answers := struct { - Codespace string - }{} - if err := survey.Ask(sshSurvey, &answers); err != nil { - return nil, fmt.Errorf("error getting answers: %v", err) - } - - codespace := codespacesByName[answers.Codespace] - return codespace, nil -} - type logger interface { Print(v ...interface{}) (int, error) Println(v ...interface{}) (int, error) @@ -123,35 +73,3 @@ func ConnectToLiveshare(ctx context.Context, log logger, apiClient *api.API, use return lsclient.JoinWorkspace(ctx) } - -// GetOrChooseCodespace prompts the user to choose a codespace if the codespaceName is empty. -// It then fetches the codespace token and the codespace record. -func GetOrChooseCodespace(ctx context.Context, apiClient *api.API, user *api.User, codespaceName string) (codespace *api.Codespace, token string, err error) { - if codespaceName == "" { - codespace, err = ChooseCodespace(ctx, apiClient, user) - if err != nil { - if err == ErrNoCodespaces { - return nil, "", err - } - return nil, "", fmt.Errorf("choosing codespace: %v", err) - } - codespaceName = codespace.Name - - token, err = apiClient.GetCodespaceToken(ctx, user.Login, codespaceName) - if err != nil { - return nil, "", fmt.Errorf("getting codespace token: %v", err) - } - } else { - token, err = apiClient.GetCodespaceToken(ctx, user.Login, codespaceName) - if err != nil { - return nil, "", fmt.Errorf("getting codespace token for given codespace: %v", err) - } - - codespace, err = apiClient.GetCodespace(ctx, token, user.Login, codespaceName) - if err != nil { - return nil, "", fmt.Errorf("getting full codespace details: %v", err) - } - } - - return codespace, token, nil -}