diff --git a/api/api.go b/api/api.go index 83510d8c5..a8f6ea724 100644 --- a/api/api.go +++ b/api/api.go @@ -1,5 +1,12 @@ +// TODO(adonovan): rename to package codespaces, and codespaces.Client. package api +// For descriptions of service interfaces, see: +// - https://online.visualstudio.com/api/swagger (for visualstudio.com) +// - https://docs.github.com/en/rest/reference/repos (for api.github.com) +// - https://github.com/github/github/blob/master/app/api/codespaces.rb (for vscs_internal) +// TODO(adonovan): replace the last link with a public doc URL when available. + import ( "bytes" "context" @@ -9,7 +16,6 @@ import ( "fmt" "io/ioutil" "net/http" - "sort" "strconv" "strings" ) @@ -29,10 +35,6 @@ type User struct { Login string `json:"login"` } -type errResponse struct { - Message string `json:"message"` -} - func (a *API) GetUser(ctx context.Context) (*User, error) { req, err := http.NewRequest(http.MethodGet, githubAPI+"/user", nil) if err != nil { @@ -44,6 +46,7 @@ func (a *API) GetUser(ctx context.Context) (*User, error) { if err != nil { return nil, fmt.Errorf("error making request: %v", err) } + defer resp.Body.Close() b, err := ioutil.ReadAll(resp.Body) if err != nil { @@ -51,7 +54,7 @@ func (a *API) GetUser(ctx context.Context) (*User, error) { } if resp.StatusCode != http.StatusOK { - return nil, a.errorResponse(b) + return nil, jsonErrorResponse(b) } var response User @@ -62,8 +65,10 @@ func (a *API) GetUser(ctx context.Context) (*User, error) { return &response, nil } -func (a *API) errorResponse(b []byte) error { - var response errResponse +func jsonErrorResponse(b []byte) error { + var response struct { + Message string `json:"message"` + } if err := json.Unmarshal(b, &response); err != nil { return fmt.Errorf("error unmarshaling error response: %v", err) } @@ -86,6 +91,7 @@ func (a *API) GetRepository(ctx context.Context, nwo string) (*Repository, error if err != nil { return nil, fmt.Errorf("error making request: %v", err) } + defer resp.Body.Close() b, err := ioutil.ReadAll(resp.Body) if err != nil { @@ -93,7 +99,7 @@ func (a *API) GetRepository(ctx context.Context, nwo string) (*Repository, error } if resp.StatusCode != http.StatusOK { - return nil, a.errorResponse(b) + return nil, jsonErrorResponse(b) } var response Repository @@ -104,14 +110,6 @@ func (a *API) GetRepository(ctx context.Context, nwo string) (*Repository, error return &response, nil } -type Codespaces []*Codespace - -func (c Codespaces) SortByCreatedAt() { - sort.Slice(c, func(i, j int) bool { - return c[i].CreatedAt > c[j].CreatedAt - }) -} - type Codespace struct { Name string `json:"name"` GUID string `json:"guid"` @@ -139,7 +137,7 @@ type CodespaceEnvironmentConnection struct { RelaySAS string `json:"relaySas"` } -func (a *API) ListCodespaces(ctx context.Context, user *User) (Codespaces, error) { +func (a *API) ListCodespaces(ctx context.Context, user *User) ([]*Codespace, error) { req, err := http.NewRequest( http.MethodGet, githubAPI+"/vscs_internal/user/"+user.Login+"/codespaces", nil, ) @@ -152,6 +150,7 @@ func (a *API) ListCodespaces(ctx context.Context, user *User) (Codespaces, error if err != nil { return nil, fmt.Errorf("error making request: %v", err) } + defer resp.Body.Close() b, err := ioutil.ReadAll(resp.Body) if err != nil { @@ -159,12 +158,12 @@ func (a *API) ListCodespaces(ctx context.Context, user *User) (Codespaces, error } if resp.StatusCode != http.StatusOK { - return nil, a.errorResponse(b) + return nil, jsonErrorResponse(b) } - response := struct { - Codespaces Codespaces `json:"codespaces"` - }{} + var response struct { + Codespaces []*Codespace `json:"codespaces"` + } if err := json.Unmarshal(b, &response); err != nil { return nil, fmt.Errorf("error unmarshaling response: %v", err) } @@ -199,6 +198,7 @@ func (a *API) GetCodespaceToken(ctx context.Context, ownerLogin, codespaceName s if err != nil { return "", fmt.Errorf("error making request: %v", err) } + defer resp.Body.Close() b, err := ioutil.ReadAll(resp.Body) if err != nil { @@ -206,7 +206,7 @@ func (a *API) GetCodespaceToken(ctx context.Context, ownerLogin, codespaceName s } if resp.StatusCode != http.StatusOK { - return "", a.errorResponse(b) + return "", jsonErrorResponse(b) } var response getCodespaceTokenResponse @@ -232,6 +232,7 @@ func (a *API) GetCodespace(ctx context.Context, token, owner, codespace string) if err != nil { return nil, fmt.Errorf("error making request: %v", err) } + defer resp.Body.Close() b, err := ioutil.ReadAll(resp.Body) if err != nil { @@ -239,7 +240,7 @@ func (a *API) GetCodespace(ctx context.Context, token, owner, codespace string) } if resp.StatusCode != http.StatusOK { - return nil, a.errorResponse(b) + return nil, jsonErrorResponse(b) } var response Codespace @@ -261,10 +262,24 @@ func (a *API) StartCodespace(ctx context.Context, token string, codespace *Codes } req.Header.Set("Authorization", "Bearer "+token) - _, err = a.client.Do(req) + resp, err := a.client.Do(req) if err != nil { return fmt.Errorf("error making request: %v", err) } + defer resp.Body.Close() + + b, err := ioutil.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("error reading response body: %v", err) + } + + if resp.StatusCode != http.StatusOK { + // Error response is numeric code and/or string message, not JSON. + if len(b) > 100 { + b = append(b[:97], "..."...) + } + return fmt.Errorf("failed to start codespace: %s", b) + } return nil } @@ -283,12 +298,17 @@ func (a *API) GetCodespaceRegionLocation(ctx context.Context) (string, error) { if err != nil { return "", fmt.Errorf("error making request: %v", err) } + defer resp.Body.Close() b, err := ioutil.ReadAll(resp.Body) if err != nil { return "", fmt.Errorf("error reading response body: %v", err) } + if resp.StatusCode != http.StatusOK { + return "", jsonErrorResponse(b) + } + var response getCodespaceRegionLocationResponse if err := json.Unmarshal(b, &response); err != nil { return "", fmt.Errorf("error unmarshaling response: %v", err) @@ -297,14 +317,12 @@ func (a *API) GetCodespaceRegionLocation(ctx context.Context) (string, error) { return response.Current, nil } -type Skus []*Sku - -type Sku struct { +type SKU struct { Name string `json:"name"` DisplayName string `json:"display_name"` } -func (a *API) GetCodespacesSkus(ctx context.Context, user *User, repository *Repository, location string) (Skus, error) { +func (a *API) GetCodespacesSKUs(ctx context.Context, user *User, repository *Repository, location string) ([]*SKU, error) { req, err := http.NewRequest(http.MethodGet, githubAPI+"/vscs_internal/user/"+user.Login+"/skus", nil) if err != nil { return nil, fmt.Errorf("err creating request: %v", err) @@ -320,20 +338,25 @@ func (a *API) GetCodespacesSkus(ctx context.Context, user *User, repository *Rep if err != nil { return nil, fmt.Errorf("error making request: %v", err) } + defer resp.Body.Close() b, err := ioutil.ReadAll(resp.Body) if err != nil { return nil, fmt.Errorf("error reading response body: %v", err) } - response := struct { - Skus Skus `json:"skus"` - }{} + if resp.StatusCode != http.StatusOK { + return nil, jsonErrorResponse(b) + } + + var response struct { + SKUs []*SKU `json:"skus"` + } if err := json.Unmarshal(b, &response); err != nil { return nil, fmt.Errorf("error unmarshaling response: %v", err) } - return response.Skus, nil + return response.SKUs, nil } type createCodespaceRequest struct { @@ -359,6 +382,7 @@ func (a *API) CreateCodespace(ctx context.Context, user *User, repository *Repos if err != nil { return nil, fmt.Errorf("error making request: %v", err) } + defer resp.Body.Close() b, err := ioutil.ReadAll(resp.Body) if err != nil { @@ -366,7 +390,7 @@ func (a *API) CreateCodespace(ctx context.Context, user *User, repository *Repos } if resp.StatusCode > http.StatusAccepted { - return nil, a.errorResponse(b) + return nil, jsonErrorResponse(b) } var response Codespace @@ -388,13 +412,14 @@ func (a *API) DeleteCodespace(ctx context.Context, user *User, token, codespaceN if err != nil { return fmt.Errorf("error making request: %v", err) } + defer resp.Body.Close() if resp.StatusCode > http.StatusAccepted { b, err := ioutil.ReadAll(resp.Body) if err != nil { return fmt.Errorf("error reading response body: %v", err) } - return a.errorResponse(b) + return jsonErrorResponse(b) } return nil @@ -419,6 +444,7 @@ func (a *API) GetCodespaceRepositoryContents(ctx context.Context, codespace *Cod if err != nil { return nil, fmt.Errorf("error making request: %v", err) } + defer resp.Body.Close() if resp.StatusCode == http.StatusNotFound { return nil, nil @@ -430,7 +456,7 @@ func (a *API) GetCodespaceRepositoryContents(ctx context.Context, codespace *Cod } if resp.StatusCode != http.StatusOK { - return nil, a.errorResponse(b) + return nil, jsonErrorResponse(b) } var response getCodespaceRepositoryContentsResponse diff --git a/cmd/ghcs/code.go b/cmd/ghcs/code.go index 81dbdbb2c..19d76fadb 100644 --- a/cmd/ghcs/code.go +++ b/cmd/ghcs/code.go @@ -12,7 +12,7 @@ import ( "github.com/spf13/cobra" ) -func NewCodeCmd() *cobra.Command { +func newCodeCmd() *cobra.Command { useInsiders := false codeCmd := &cobra.Command{ @@ -24,7 +24,7 @@ func NewCodeCmd() *cobra.Command { if len(args) > 0 { codespaceName = args[0] } - return Code(codespaceName, useInsiders) + return code(codespaceName, useInsiders) }, } @@ -34,10 +34,10 @@ func NewCodeCmd() *cobra.Command { } func init() { - rootCmd.AddCommand(NewCodeCmd()) + rootCmd.AddCommand(newCodeCmd()) } -func Code(codespaceName string, useInsiders bool) error { +func code(codespaceName string, useInsiders bool) error { apiClient := api.New(os.Getenv("GITHUB_TOKEN")) ctx := context.Background() @@ -57,8 +57,9 @@ func Code(codespaceName string, useInsiders bool) error { codespaceName = codespace.Name } - if err := open.Run(vscodeProtocolURL(codespaceName, useInsiders)); err != nil { - return fmt.Errorf("error opening vscode URL") + url := vscodeProtocolURL(codespaceName, useInsiders) + if err := open.Run(url); err != nil { + return fmt.Errorf("error opening vscode URL %s: %s. (Is VSCode installed?)", url, err) } return nil diff --git a/cmd/ghcs/create.go b/cmd/ghcs/create.go index c3e8a24a1..7d5d59923 100644 --- a/cmd/ghcs/create.go +++ b/cmd/ghcs/create.go @@ -11,24 +11,33 @@ import ( "github.com/fatih/camelcase" "github.com/github/ghcs/api" "github.com/github/ghcs/cmd/ghcs/output" + "github.com/github/ghcs/internal/codespaces" "github.com/spf13/cobra" ) -var repo, branch, machine string +type createOptions struct { + repo string + branch string + machine string + showStatus bool +} func newCreateCmd() *cobra.Command { + opts := &createOptions{} + createCmd := &cobra.Command{ Use: "create", Short: "Create a Codespace", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { - return Create() + return create(opts) }, } - createCmd.Flags().StringVarP(&repo, "repo", "r", "", "repository name with owner: user/repo") - createCmd.Flags().StringVarP(&branch, "branch", "b", "", "repository branch") - createCmd.Flags().StringVarP(&machine, "machine", "m", "", "hardware specifications for the VM") + createCmd.Flags().StringVarP(&opts.repo, "repo", "r", "", "repository name with owner: user/repo") + createCmd.Flags().StringVarP(&opts.branch, "branch", "b", "", "repository branch") + createCmd.Flags().StringVarP(&opts.machine, "machine", "m", "", "hardware specifications for the VM") + createCmd.Flags().BoolVarP(&opts.showStatus, "status", "s", false, "show status of post-create command and dotfiles") return createCmd } @@ -37,18 +46,18 @@ func init() { rootCmd.AddCommand(newCreateCmd()) } -func Create() error { +func create(opts *createOptions) error { ctx := context.Background() apiClient := api.New(os.Getenv("GITHUB_TOKEN")) locationCh := getLocation(ctx, apiClient) userCh := getUser(ctx, apiClient) log := output.NewLogger(os.Stdout, os.Stderr, false) - repo, err := getRepoName() + repo, err := getRepoName(opts.repo) if err != nil { return fmt.Errorf("error getting repository name: %v", err) } - branch, err := getBranchName() + branch, err := getBranchName(opts.branch) if err != nil { return fmt.Errorf("error getting branch name: %v", err) } @@ -68,7 +77,7 @@ func Create() error { return fmt.Errorf("error getting codespace user: %v", userResult.Err) } - machine, err := getMachineName(ctx, userResult.User, repository, locationResult.Location, apiClient) + machine, err := getMachineName(ctx, opts.machine, userResult.User, repository, locationResult.Location, apiClient) if err != nil { return fmt.Errorf("error getting machine type: %v", err) } @@ -83,6 +92,12 @@ func Create() error { return fmt.Errorf("error creating codespace: %v", err) } + if opts.showStatus { + if err := showStatus(ctx, log, apiClient, userResult.User, codespace); err != nil { + return fmt.Errorf("show status: %w", err) + } + } + log.Printf("Codespace created: ") fmt.Fprintln(os.Stdout, codespace.Name) @@ -90,11 +105,67 @@ func Create() error { return nil } +func showStatus(ctx context.Context, log *output.Logger, apiClient *api.API, user *api.User, codespace *api.Codespace) error { + var lastState codespaces.PostCreateState + var breakNextState bool + + finishedStates := make(map[string]bool) + ctx, stopPolling := context.WithCancel(ctx) + + poller := func(states []codespaces.PostCreateState) { + var inProgress bool + for _, state := range states { + if _, found := finishedStates[state.Name]; found { + continue // skip this state as we've processed it already + } + + if state.Name != lastState.Name { + log.Print(state.Name) + + if state.Status == codespaces.PostCreateStateRunning { + inProgress = true + lastState = state + log.Print("...") + break + } + + finishedStates[state.Name] = true + log.Println("..." + state.Status) + } else { + if state.Status == codespaces.PostCreateStateRunning { + inProgress = true + log.Print(".") + break + } + + finishedStates[state.Name] = true + log.Println(state.Status) + lastState = codespaces.PostCreateState{} // reset the value + } + } + + if !inProgress { + if breakNextState { + stopPolling() + return + } + breakNextState = true + } + } + + if err := codespaces.PollPostCreateStates(ctx, log, apiClient, user, codespace, poller); err != nil { + return fmt.Errorf("failed to poll state changes from codespace: %v", err) + } + + return nil +} + type getUserResult struct { User *api.User Err error } +// getUser fetches the user record associated with the GITHUB_TOKEN func getUser(ctx context.Context, apiClient *api.API) <-chan getUserResult { ch := make(chan getUserResult) go func() { @@ -109,6 +180,7 @@ type locationResult struct { Err error } +// getLocation fetches the closest Codespace datacenter region/location to the user. func getLocation(ctx context.Context, apiClient *api.API) <-chan locationResult { ch := make(chan locationResult) go func() { @@ -118,7 +190,8 @@ func getLocation(ctx context.Context, apiClient *api.API) <-chan locationResult return ch } -func getRepoName() (string, error) { +// getRepoName prompts the user for the name of the repository, or returns the repository if non-empty. +func getRepoName(repo string) (string, error) { if repo != "" { return repo, nil } @@ -126,15 +199,16 @@ func getRepoName() (string, error) { repoSurvey := []*survey.Question{ { Name: "repository", - Prompt: &survey.Input{Message: "Repository"}, + Prompt: &survey.Input{Message: "Repository:"}, Validate: survey.Required, }, } - err := survey.Ask(repoSurvey, &repo) + err := ask(repoSurvey, &repo) return repo, err } -func getBranchName() (string, error) { +// getBranchName prompts the user for the name of the branch, or returns the branch if non-empty. +func getBranchName(branch string) (string, error) { if branch != "" { return branch, nil } @@ -142,18 +216,19 @@ func getBranchName() (string, error) { branchSurvey := []*survey.Question{ { Name: "branch", - Prompt: &survey.Input{Message: "Branch"}, + Prompt: &survey.Input{Message: "Branch:"}, Validate: survey.Required, }, } - err := survey.Ask(branchSurvey, &branch) + err := ask(branchSurvey, &branch) return branch, err } -func getMachineName(ctx context.Context, user *api.User, repo *api.Repository, location string, apiClient *api.API) (string, error) { - skus, err := apiClient.GetCodespacesSkus(ctx, user, repo, location) +// getMachineName prompts the user to select the machine type, or validates the machine if non-empty. +func getMachineName(ctx context.Context, machine string, user *api.User, repo *api.Repository, location string, apiClient *api.API) (string, error) { + skus, err := apiClient.GetCodespacesSKUs(ctx, user, repo, location) if err != nil { - return "", fmt.Errorf("error getting codespace skus: %v", err) + return "", fmt.Errorf("error getting codespace SKUs: %v", err) } // if user supplied a machine type, it must be valid @@ -165,18 +240,18 @@ func getMachineName(ctx context.Context, user *api.User, repo *api.Repository, l } } - availableSkus := make([]string, len(skus)) + availableSKUs := make([]string, len(skus)) for i := 0; i < len(skus); i++ { - availableSkus[i] = skus[i].Name + availableSKUs[i] = skus[i].Name } - return "", fmt.Errorf("there are is no such machine for the repository: %s\nAvailable machines: %v", machine, availableSkus) + return "", fmt.Errorf("there is no such machine for the repository: %s\nAvailable machines: %v", machine, availableSKUs) } else if len(skus) == 0 { return "", nil } skuNames := make([]string, 0, len(skus)) - skuByName := make(map[string]*api.Sku) + skuByName := make(map[string]*api.SKU) for _, sku := range skus { nameParts := camelcase.Split(sku.Name) machineName := strings.Title(strings.ToLower(nameParts[0])) @@ -197,8 +272,8 @@ func getMachineName(ctx context.Context, user *api.User, repo *api.Repository, l }, } - skuAnswers := struct{ SKU string }{} - if err := survey.Ask(skuSurvey, &skuAnswers); err != nil { + var skuAnswers struct{ SKU string } + if err := ask(skuSurvey, &skuAnswers); err != nil { return "", fmt.Errorf("error getting SKU: %v", err) } @@ -207,3 +282,8 @@ func getMachineName(ctx context.Context, user *api.User, repo *api.Repository, l 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 c357171d1..d37029753 100644 --- a/cmd/ghcs/delete.go +++ b/cmd/ghcs/delete.go @@ -12,7 +12,7 @@ import ( "github.com/spf13/cobra" ) -func NewDeleteCmd() *cobra.Command { +func newDeleteCmd() *cobra.Command { deleteCmd := &cobra.Command{ Use: "delete []", Short: "Delete a Codespace", @@ -22,7 +22,7 @@ func NewDeleteCmd() *cobra.Command { if len(args) > 0 { codespaceName = args[0] } - return Delete(codespaceName) + return delete_(codespaceName) }, } @@ -31,7 +31,7 @@ func NewDeleteCmd() *cobra.Command { Short: "Delete all Codespaces for the current user", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { - return DeleteAll() + return deleteAll() }, } @@ -40,7 +40,7 @@ func NewDeleteCmd() *cobra.Command { Short: "Delete all Codespaces for a repository", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - return DeleteByRepo(args[0]) + return deleteByRepo(args[0]) }, } @@ -50,10 +50,10 @@ func NewDeleteCmd() *cobra.Command { } func init() { - rootCmd.AddCommand(NewDeleteCmd()) + rootCmd.AddCommand(newDeleteCmd()) } -func Delete(codespaceName string) error { +func delete_(codespaceName string) error { apiClient := api.New(os.Getenv("GITHUB_TOKEN")) ctx := context.Background() log := output.NewLogger(os.Stdout, os.Stderr, false) @@ -74,10 +74,10 @@ func Delete(codespaceName string) error { log.Println("Codespace deleted.") - return List(&ListOptions{}) + return list(&listOptions{}) } -func DeleteAll() error { +func deleteAll() error { apiClient := api.New(os.Getenv("GITHUB_TOKEN")) ctx := context.Background() log := output.NewLogger(os.Stdout, os.Stderr, false) @@ -105,10 +105,10 @@ func DeleteAll() error { log.Printf("Codespace deleted: %s\n", c.Name) } - return List(&ListOptions{}) + return list(&listOptions{}) } -func DeleteByRepo(repo string) error { +func deleteByRepo(repo string) error { apiClient := api.New(os.Getenv("GITHUB_TOKEN")) ctx := context.Background() log := output.NewLogger(os.Stdout, os.Stderr, false) @@ -146,5 +146,5 @@ func DeleteByRepo(repo string) error { return fmt.Errorf("No codespace was found for repository: %s", repo) } - return List(&ListOptions{}) + return list(&listOptions{}) } diff --git a/cmd/ghcs/list.go b/cmd/ghcs/list.go index 27b11d4fd..a19439296 100644 --- a/cmd/ghcs/list.go +++ b/cmd/ghcs/list.go @@ -10,32 +10,32 @@ import ( "github.com/spf13/cobra" ) -type ListOptions struct { - AsJSON bool +type listOptions struct { + asJSON bool } -func NewListCmd() *cobra.Command { - opts := &ListOptions{} +func newListCmd() *cobra.Command { + opts := &listOptions{} listCmd := &cobra.Command{ Use: "list", Short: "List your Codespaces", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { - return List(opts) + return list(opts) }, } - listCmd.Flags().BoolVar(&opts.AsJSON, "json", false, "Output as JSON") + listCmd.Flags().BoolVar(&opts.asJSON, "json", false, "Output as JSON") return listCmd } func init() { - rootCmd.AddCommand(NewListCmd()) + rootCmd.AddCommand(newListCmd()) } -func List(opts *ListOptions) error { +func list(opts *listOptions) error { apiClient := api.New(os.Getenv("GITHUB_TOKEN")) ctx := context.Background() @@ -49,7 +49,7 @@ func List(opts *ListOptions) error { return fmt.Errorf("error getting codespaces: %v", err) } - table := output.NewTable(os.Stdout, opts.AsJSON) + table := output.NewTable(os.Stdout, opts.asJSON) table.SetHeader([]string{"Name", "Repository", "Branch", "State", "Created At"}) for _, codespace := range codespaces { table.Append([]string{ diff --git a/cmd/ghcs/logs.go b/cmd/ghcs/logs.go index 31af10dda..ea5531bac 100644 --- a/cmd/ghcs/logs.go +++ b/cmd/ghcs/logs.go @@ -12,7 +12,7 @@ import ( "github.com/spf13/cobra" ) -func NewLogsCmd() *cobra.Command { +func newLogsCmd() *cobra.Command { var tail bool logsCmd := &cobra.Command{ @@ -24,7 +24,7 @@ func NewLogsCmd() *cobra.Command { if len(args) > 0 { codespaceName = args[0] } - return Logs(tail, codespaceName) + return logs(tail, codespaceName) }, } @@ -34,10 +34,10 @@ func NewLogsCmd() *cobra.Command { } func init() { - rootCmd.AddCommand(NewLogsCmd()) + rootCmd.AddCommand(newLogsCmd()) } -func Logs(tail bool, codespaceName string) error { +func logs(tail bool, codespaceName string) error { apiClient := api.New(os.Getenv("GITHUB_TOKEN")) ctx := context.Background() log := output.NewLogger(os.Stdout, os.Stderr, false) @@ -52,7 +52,7 @@ func Logs(tail bool, codespaceName string) error { return fmt.Errorf("get or choose codespace: %v", err) } - lsclient, err := codespaces.ConnectToLiveshare(ctx, log, apiClient, token, codespace) + lsclient, err := codespaces.ConnectToLiveshare(ctx, log, apiClient, user.Login, token, codespace) if err != nil { return fmt.Errorf("connecting to liveshare: %v", err) } diff --git a/cmd/ghcs/main.go b/cmd/ghcs/main.go index 58037437a..bc9bc2c6b 100644 --- a/cmd/ghcs/main.go +++ b/cmd/ghcs/main.go @@ -16,7 +16,7 @@ func main() { } } -var Version = "DEV" +var version = "DEV" var rootCmd = &cobra.Command{ Use: "ghcs", @@ -24,7 +24,7 @@ var rootCmd = &cobra.Command{ 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") == "" { @@ -42,5 +42,5 @@ func explainError(w io.Writer, err error) { fmt.Fprintln(w, "Make sure to enable SSO for your organizations after creating the token.") return } - // fmt.Fprintf(w, "%v\n", err) + fmt.Fprintf(w, "%v\n", err) } diff --git a/cmd/ghcs/ports.go b/cmd/ghcs/ports.go index 09397af54..3e89eb984 100644 --- a/cmd/ghcs/ports.go +++ b/cmd/ghcs/ports.go @@ -19,48 +19,54 @@ import ( "golang.org/x/sync/errgroup" ) -type PortsOptions struct { - CodespaceName string - AsJSON bool +// portOptions represents the options accepted by the ports command. +type portsOptions struct { + // CodespaceName is the name of the codespace, optional. + codespaceName string + + // AsJSON dictates whether the command returns a json output or not, optional. + asJSON bool } -func NewPortsCmd() *cobra.Command { - opts := &PortsOptions{} +// newPortsCmd returns a Cobra "ports" command that displays a table of available ports, +// according to the specified flags. +func newPortsCmd() *cobra.Command { + opts := &portsOptions{} portsCmd := &cobra.Command{ Use: "ports", Short: "List ports in a Codespace", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { - return Ports(opts) + return ports(opts) }, } - portsCmd.Flags().StringVarP(&opts.CodespaceName, "codespace", "c", "", "The `name` of the Codespace to use") - portsCmd.Flags().BoolVar(&opts.AsJSON, "json", false, "Output as JSON") + portsCmd.Flags().StringVarP(&opts.codespaceName, "codespace", "c", "", "The `name` of the Codespace to use") + portsCmd.Flags().BoolVar(&opts.asJSON, "json", false, "Output as JSON") - portsCmd.AddCommand(NewPortsPublicCmd()) - portsCmd.AddCommand(NewPortsPrivateCmd()) - portsCmd.AddCommand(NewPortsForwardCmd()) + portsCmd.AddCommand(newPortsPublicCmd()) + portsCmd.AddCommand(newPortsPrivateCmd()) + portsCmd.AddCommand(newPortsForwardCmd()) return portsCmd } func init() { - rootCmd.AddCommand(NewPortsCmd()) + rootCmd.AddCommand(newPortsCmd()) } -func Ports(opts *PortsOptions) error { +func ports(opts *portsOptions) error { apiClient := api.New(os.Getenv("GITHUB_TOKEN")) ctx := context.Background() - log := output.NewLogger(os.Stdout, os.Stderr, opts.AsJSON) + log := output.NewLogger(os.Stdout, os.Stderr, opts.asJSON) user, err := apiClient.GetUser(ctx) if err != nil { return fmt.Errorf("error getting user: %v", err) } - codespace, token, err := codespaces.GetOrChooseCodespace(ctx, apiClient, user, opts.CodespaceName) + codespace, token, err := codespaces.GetOrChooseCodespace(ctx, apiClient, user, opts.codespaceName) if err != nil { if err == codespaces.ErrNoCodespaces { return err @@ -70,7 +76,7 @@ func Ports(opts *PortsOptions) error { devContainerCh := getDevContainer(ctx, apiClient, codespace) - liveShareClient, err := codespaces.ConnectToLiveshare(ctx, log, apiClient, token, codespace) + liveShareClient, err := codespaces.ConnectToLiveshare(ctx, log, apiClient, user.Login, token, codespace) if err != nil { return fmt.Errorf("error connecting to liveshare: %v", err) } @@ -82,17 +88,18 @@ func Ports(opts *PortsOptions) error { } devContainerResult := <-devContainerCh - if devContainerResult.Err != nil { - _, _ = log.Errorf("Failed to get port names: %v\n", devContainerResult.Err.Error()) + if devContainerResult.err != nil { + // Warn about failure to read the devcontainer file. Not a ghcs command error. + _, _ = log.Errorf("Failed to get port names: %v\n", devContainerResult.err.Error()) } - table := output.NewTable(os.Stdout, opts.AsJSON) - table.SetHeader([]string{"Label", "Source Port", "Destination Port", "Public", "Browse URL"}) + table := output.NewTable(os.Stdout, opts.asJSON) + table.SetHeader([]string{"Label", "Port", "Public", "Browse URL"}) for _, port := range ports { sourcePort := strconv.Itoa(port.SourcePort) var portName string - if devContainerResult.DevContainer != nil { - if attributes, ok := devContainerResult.DevContainer.PortAttributes[sourcePort]; ok { + if devContainerResult.devContainer != nil { + if attributes, ok := devContainerResult.devContainer.PortAttributes[sourcePort]; ok { portName = attributes.Label } } @@ -100,7 +107,6 @@ func Ports(opts *PortsOptions) error { table.Append([]string{ portName, sourcePort, - strconv.Itoa(port.DestinationPort), strings.ToUpper(strconv.FormatBool(port.IsPublic)), fmt.Sprintf("https://%s-%s.githubpreview.dev/", codespace.Name, sourcePort), }) @@ -125,8 +131,8 @@ func getPorts(ctx context.Context, lsclient *liveshare.Client) (liveshare.Ports, } type devContainerResult struct { - DevContainer *devContainer - Err error + devContainer *devContainer + err error } type devContainer struct { @@ -168,7 +174,8 @@ func getDevContainer(ctx context.Context, apiClient *api.API, codespace *api.Cod return ch } -func NewPortsPublicCmd() *cobra.Command { +// newPortsPublicCmd returns a Cobra "ports public" subcommand, which makes a given port public. +func newPortsPublicCmd() *cobra.Command { return &cobra.Command{ Use: "public ", Short: "Mark port as public", @@ -180,7 +187,8 @@ func NewPortsPublicCmd() *cobra.Command { } } -func NewPortsPrivateCmd() *cobra.Command { +// newPortsPrivateCmd returns a Cobra "ports private" subcommand, which makes a given port private. +func newPortsPrivateCmd() *cobra.Command { return &cobra.Command{ Use: "private ", Short: "Mark port as private", @@ -211,7 +219,7 @@ func updatePortVisibility(log *output.Logger, codespaceName, sourcePort string, return fmt.Errorf("error getting codespace: %v", err) } - lsclient, err := codespaces.ConnectToLiveshare(ctx, log, apiClient, token, codespace) + lsclient, err := codespaces.ConnectToLiveshare(ctx, log, apiClient, user.Login, token, codespace) if err != nil { return fmt.Errorf("error connecting to liveshare: %v", err) } @@ -239,7 +247,9 @@ func updatePortVisibility(log *output.Logger, codespaceName, sourcePort string, return nil } -func NewPortsForwardCmd() *cobra.Command { +// NewPortsForwardCmd returns a Cobra "ports forward" subcommand, which forwards a set of +// port pairs from the codespace to localhost. +func newPortsForwardCmd() *cobra.Command { return &cobra.Command{ Use: "forward :", Short: "Forward ports", @@ -275,7 +285,7 @@ func forwardPorts(log *output.Logger, codespaceName string, ports []string) erro return fmt.Errorf("error getting codespace: %v", err) } - lsclient, err := codespaces.ConnectToLiveshare(ctx, log, apiClient, token, codespace) + lsclient, err := codespaces.ConnectToLiveshare(ctx, log, apiClient, user.Login, token, codespace) if err != nil { return fmt.Errorf("error connecting to liveshare: %v", err) } @@ -289,14 +299,14 @@ func forwardPorts(log *output.Logger, codespaceName string, ports []string) erro for _, portPair := range portPairs { pp := portPair - srcstr := strconv.Itoa(portPair.Src) - if err := server.StartSharing(gctx, "share-"+srcstr, pp.Src); err != nil { + srcstr := strconv.Itoa(portPair.src) + if err := server.StartSharing(gctx, "share-"+srcstr, pp.src); err != nil { return fmt.Errorf("start sharing port: %v", err) } g.Go(func() error { - log.Println("Forwarding port: " + srcstr + " ==> " + strconv.Itoa(pp.Dst)) - portForwarder := liveshare.NewPortForwarder(lsclient, server, pp.Dst) + log.Println("Forwarding port: " + srcstr + " ==> " + strconv.Itoa(pp.dst)) + portForwarder := liveshare.NewPortForwarder(lsclient, server, pp.dst) if err := portForwarder.Start(gctx); err != nil { return fmt.Errorf("error forwarding port: %v", err) } @@ -313,16 +323,17 @@ func forwardPorts(log *output.Logger, codespaceName string, ports []string) erro } type portPair struct { - Src, Dst int + src, dst int } +// getPortPairs parses a list of strings of form "%d:%d" into pairs of numbers. func getPortPairs(ports []string) ([]portPair, error) { pp := make([]portPair, 0, len(ports)) for _, portString := range ports { parts := strings.Split(portString, ":") if len(parts) < 2 { - return pp, fmt.Errorf("port pair: '%v' is not valid", portString) + return nil, fmt.Errorf("port pair: '%v' is not valid", portString) } srcp, err := strconv.Atoi(parts[0]) diff --git a/cmd/ghcs/ssh.go b/cmd/ghcs/ssh.go index e3e51e08e..fd98397fe 100644 --- a/cmd/ghcs/ssh.go +++ b/cmd/ghcs/ssh.go @@ -15,7 +15,7 @@ import ( "github.com/spf13/cobra" ) -func NewSSHCmd() *cobra.Command { +func newSSHCmd() *cobra.Command { var sshProfile, codespaceName string var sshServerPort int @@ -24,7 +24,7 @@ func NewSSHCmd() *cobra.Command { Short: "SSH into a Codespace", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { - return SSH(sshProfile, codespaceName, sshServerPort) + return ssh(sshProfile, codespaceName, sshServerPort) }, } @@ -36,10 +36,10 @@ func NewSSHCmd() *cobra.Command { } func init() { - rootCmd.AddCommand(NewSSHCmd()) + rootCmd.AddCommand(newSSHCmd()) } -func SSH(sshProfile, codespaceName string, sshServerPort int) error { +func ssh(sshProfile, codespaceName string, sshServerPort int) error { apiClient := api.New(os.Getenv("GITHUB_TOKEN")) ctx := context.Background() log := output.NewLogger(os.Stdout, os.Stderr, false) @@ -54,7 +54,7 @@ func SSH(sshProfile, codespaceName string, sshServerPort int) error { return fmt.Errorf("get or choose codespace: %v", err) } - lsclient, err := codespaces.ConnectToLiveshare(ctx, log, apiClient, token, codespace) + lsclient, err := codespaces.ConnectToLiveshare(ctx, log, apiClient, user.Login, token, codespace) if err != nil { return fmt.Errorf("error connecting to liveshare: %v", err) } diff --git a/internal/codespaces/codespaces.go b/internal/codespaces/codespaces.go index 48369cfa0..3214a6dea 100644 --- a/internal/codespaces/codespaces.go +++ b/internal/codespaces/codespaces.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "sort" "time" "github.com/AlecAivazis/survey/v2" @@ -25,7 +26,9 @@ func ChooseCodespace(ctx context.Context, apiClient *api.API, user *api.User) (* return nil, ErrNoCodespaces } - codespaces.SortByCreatedAt() + 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)) @@ -62,16 +65,25 @@ type logger interface { Println(v ...interface{}) (int, error) } -func ConnectToLiveshare(ctx context.Context, log logger, apiClient *api.API, token string, codespace *api.Codespace) (client *liveshare.Client, err error) { +func connectionReady(codespace *api.Codespace) bool { + return codespace.Environment.Connection.SessionID != "" && + codespace.Environment.Connection.SessionToken != "" && + codespace.Environment.Connection.RelayEndpoint != "" && + codespace.Environment.Connection.RelaySAS != "" && + codespace.Environment.State == api.CodespaceEnvironmentStateAvailable +} + +func ConnectToLiveshare(ctx context.Context, log logger, apiClient *api.API, userLogin, token string, codespace *api.Codespace) (client *liveshare.Client, err error) { + var startedCodespace bool if codespace.Environment.State != api.CodespaceEnvironmentStateAvailable { - log.Println("Starting your codespace...") + startedCodespace = true + log.Print("Starting your codespace...") if err := apiClient.StartCodespace(ctx, token, codespace); err != nil { return nil, fmt.Errorf("error starting codespace: %v", err) } } - retries := 0 - for codespace.Environment.Connection.SessionID == "" || codespace.Environment.State != api.CodespaceEnvironmentStateAvailable { + for retries := 0; !connectionReady(codespace); retries++ { if retries > 1 { if retries%2 == 0 { log.Print(".") @@ -84,16 +96,14 @@ func ConnectToLiveshare(ctx context.Context, log logger, apiClient *api.API, tok return nil, errors.New("timed out while waiting for the codespace to start") } - codespace, err = apiClient.GetCodespace(ctx, token, codespace.OwnerLogin, codespace.Name) + codespace, err = apiClient.GetCodespace(ctx, token, userLogin, codespace.Name) if err != nil { return nil, fmt.Errorf("error getting codespace: %v", err) } - - retries += 1 } - if retries >= 2 { - log.Print("\n") + if startedCodespace { + fmt.Print("\n") } log.Println("Connecting to your codespace...") diff --git a/internal/codespaces/states.go b/internal/codespaces/states.go new file mode 100644 index 000000000..5c3dcef45 --- /dev/null +++ b/internal/codespaces/states.go @@ -0,0 +1,105 @@ +package codespaces + +import ( + "context" + "encoding/json" + "fmt" + "io/ioutil" + "strings" + "time" + + "github.com/github/ghcs/api" +) + +// PostCreateStateStatus is a string value representing the different statuses a state can have. +type PostCreateStateStatus string + +func (p PostCreateStateStatus) String() string { + return strings.Title(string(p)) +} + +const ( + PostCreateStateRunning PostCreateStateStatus = "running" + PostCreateStateSuccess PostCreateStateStatus = "succeeded" + PostCreateStateFailed PostCreateStateStatus = "failed" +) + +// PostCreateState is a combination of a state and status value that is captured +// during codespace creation. +type PostCreateState struct { + Name string `json:"name"` + Status PostCreateStateStatus `json:"status"` +} + +// PollPostCreateStates watches for state changes in a codespace, +// and calls the supplied poller for each batch of state changes. +// It runs until the context is cancelled or SSH tunnel is closed. +func PollPostCreateStates(ctx context.Context, log logger, apiClient *api.API, user *api.User, codespace *api.Codespace, poller func([]PostCreateState)) error { + token, err := apiClient.GetCodespaceToken(ctx, user.Login, codespace.Name) + if err != nil { + return fmt.Errorf("getting codespace token: %v", err) + } + + lsclient, err := ConnectToLiveshare(ctx, log, apiClient, user.Login, token, codespace) + if err != nil { + return fmt.Errorf("connect to liveshare: %v", err) + } + + tunnelPort, connClosed, err := MakeSSHTunnel(ctx, lsclient, 0) + if err != nil { + return fmt.Errorf("make ssh tunnel: %v", err) + } + + t := time.NewTicker(1 * time.Second) + defer t.Stop() + + for { + select { + case <-ctx.Done(): + return nil + case err := <-connClosed: + return fmt.Errorf("connection closed: %v", err) + case <-t.C: + states, err := getPostCreateOutput(ctx, tunnelPort, codespace) + if err != nil { + return fmt.Errorf("get post create output: %v", err) + } + + poller(states) + } + } +} + +func getPostCreateOutput(ctx context.Context, tunnelPort int, codespace *api.Codespace) ([]PostCreateState, error) { + stdout, err := RunCommand( + ctx, tunnelPort, sshDestination(codespace), + "cat /workspaces/.codespaces/shared/postCreateOutput.json", + ) + if err != nil { + return nil, fmt.Errorf("run command: %v", err) + } + defer stdout.Close() + + b, err := ioutil.ReadAll(stdout) + if err != nil { + return nil, fmt.Errorf("read output: %v", err) + } + + var output struct { + Steps []PostCreateState `json:"steps"` + } + if err := json.Unmarshal(b, &output); err != nil { + return nil, fmt.Errorf("unmarshal output: %v", err) + } + + return output.Steps, nil +} + +// TODO(josebalius): this won't be needed soon +func sshDestination(codespace *api.Codespace) string { + user := "codespace" + if codespace.RepositoryNWO == "github/github" { + user = "root" + } + return user + "@localhost" +}