Merge remote-tracking branch 'origin/main' into edgonmsft/codespaces-ssh-rpc

This commit is contained in:
Edmundo Gonzalez 2021-08-30 05:01:22 +00:00 committed by GitHub
commit ee36a005b1
11 changed files with 378 additions and 145 deletions

View file

@ -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

View file

@ -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

View file

@ -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))
}

View file

@ -12,7 +12,7 @@ import (
"github.com/spf13/cobra"
)
func NewDeleteCmd() *cobra.Command {
func newDeleteCmd() *cobra.Command {
deleteCmd := &cobra.Command{
Use: "delete [<codespace>]",
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{})
}

View file

@ -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{

View file

@ -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)
}

View file

@ -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)
}

View file

@ -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 <codespace> <port>",
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 <codespace> <port>",
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 <codespace> <source-port>:<destination-port>",
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])

View file

@ -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)
}

View file

@ -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...")

View file

@ -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"
}