diff --git a/cmd/ghcs/create.go b/cmd/ghcs/create.go index 45aa794e6..ae64b94a3 100644 --- a/cmd/ghcs/create.go +++ b/cmd/ghcs/create.go @@ -81,9 +81,15 @@ func create(opts *createOptions) error { return errors.New("there are no available machine types for this repository") } - log.Println("Creating your codespace...") - - codespace, err := apiClient.CreateCodespace(ctx, userResult.User, repository, machine, branch, locationResult.Location) + log.Print("Creating your codespace...") + codespace, err := apiClient.CreateCodespace(ctx, log, &api.CreateCodespaceParams{ + User: userResult.User.Login, + RepositoryID: repository.ID, + Branch: branch, + Machine: machine, + Location: locationResult.Location, + }) + log.Print("\n") if err != nil { return fmt.Errorf("error creating codespace: %w", err) } @@ -172,7 +178,7 @@ type getUserResult struct { // getUser fetches the user record associated with the GITHUB_TOKEN func getUser(ctx context.Context, apiClient *api.API) <-chan getUserResult { - ch := make(chan getUserResult) + ch := make(chan getUserResult, 1) go func() { user, err := apiClient.GetUser(ctx) ch <- getUserResult{user, err} @@ -187,7 +193,7 @@ type locationResult struct { // 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) + ch := make(chan locationResult, 1) go func() { location, err := apiClient.GetCodespaceRegionLocation(ctx) ch <- locationResult{location, err} diff --git a/cmd/ghcs/ports.go b/cmd/ghcs/ports.go index aeecf0a07..8a4f855fa 100644 --- a/cmd/ghcs/ports.go +++ b/cmd/ghcs/ports.go @@ -123,7 +123,7 @@ type portAttribute struct { } func getDevContainer(ctx context.Context, apiClient *api.API, codespace *api.Codespace) <-chan devContainerResult { - ch := make(chan devContainerResult) + ch := make(chan devContainerResult, 1) go func() { contents, err := apiClient.GetCodespaceRepositoryContents(ctx, codespace, ".devcontainer/devcontainer.json") if err != nil { diff --git a/internal/api/api.go b/internal/api/api.go index 4d4078c9c..bfccfc6c9 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -36,6 +36,7 @@ import ( "net/http" "strconv" "strings" + "time" "github.com/opentracing/opentracing-go" ) @@ -214,6 +215,10 @@ type getCodespaceTokenResponse struct { RepositoryToken string `json:"repository_token"` } +// ErrNotProvisioned is returned by GetCodespacesToken to indicate that the +// creation of a codespace is not yet complete and that the caller should try again. +var ErrNotProvisioned = errors.New("codespace not provisioned") + func (a *API) GetCodespaceToken(ctx context.Context, ownerLogin, codespaceName string) (string, error) { reqBody, err := json.Marshal(getCodespaceTokenRequest{true}) if err != nil { @@ -242,6 +247,10 @@ func (a *API) GetCodespaceToken(ctx context.Context, ownerLogin, codespaceName s } if resp.StatusCode != http.StatusOK { + if resp.StatusCode == http.StatusUnprocessableEntity { + return "", ErrNotProvisioned + } + return "", jsonErrorResponse(b) } @@ -401,20 +410,83 @@ func (a *API) GetCodespacesSKUs(ctx context.Context, user *User, repository *Rep return response.SKUs, nil } -type createCodespaceRequest struct { +// CreateCodespaceParams are the required parameters for provisioning a Codespace. +type CreateCodespaceParams struct { + User string + RepositoryID int + Branch, Machine, Location string +} + +type logger interface { + Print(v ...interface{}) (int, error) + Println(v ...interface{}) (int, error) +} + +// CreateCodespace creates a codespace with the given parameters and returns a non-nil error if it +// fails to create. +func (a *API) CreateCodespace(ctx context.Context, log logger, params *CreateCodespaceParams) (*Codespace, error) { + codespace, err := a.startCreate( + ctx, params.User, params.RepositoryID, params.Machine, params.Branch, params.Location, + ) + if err != errProvisioningInProgress { + return codespace, err + } + + // errProvisioningInProgress indicates that codespace creation did not complete + // within the GitHub API RPC time limit (10s), so it continues asynchronously. + // We must poll the server to discover the outcome. + ctx, cancel := context.WithTimeout(ctx, 2*time.Minute) + defer cancel() + + ticker := time.NewTicker(1 * time.Second) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-ticker.C: + log.Print(".") + token, err := a.GetCodespaceToken(ctx, params.User, codespace.Name) + if err != nil { + if err == ErrNotProvisioned { + // Do nothing. We expect this to fail until the codespace is provisioned + continue + } + + return nil, fmt.Errorf("failed to get codespace token: %w", err) + } + + codespace, err = a.GetCodespace(ctx, token, params.User, codespace.Name) + if err != nil { + return nil, fmt.Errorf("failed to get codespace: %w", err) + } + + return codespace, nil + } + } +} + +type startCreateRequest struct { RepositoryID int `json:"repository_id"` Ref string `json:"ref"` Location string `json:"location"` SkuName string `json:"sku_name"` } -func (a *API) CreateCodespace(ctx context.Context, user *User, repository *Repository, sku, branch, location string) (*Codespace, error) { - requestBody, err := json.Marshal(createCodespaceRequest{repository.ID, branch, location, sku}) +var errProvisioningInProgress = errors.New("provisioning in progress") + +// startCreate starts the creation of a codespace. +// It may return success or an error, or errProvisioningInProgress indicating that the operation +// did not complete before the GitHub API's time limit for RPCs (10s), in which case the caller +// must poll the server to learn the outcome. +func (a *API) startCreate(ctx context.Context, user string, repository int, sku, branch, location string) (*Codespace, error) { + requestBody, err := json.Marshal(startCreateRequest{repository, branch, location, sku}) if err != nil { return nil, fmt.Errorf("error marshaling request: %w", err) } - req, err := http.NewRequest(http.MethodPost, a.githubAPI+"/vscs_internal/user/"+user.Login+"/codespaces", bytes.NewBuffer(requestBody)) + req, err := http.NewRequest(http.MethodPost, a.githubAPI+"/vscs_internal/user/"+user+"/codespaces", bytes.NewBuffer(requestBody)) if err != nil { return nil, fmt.Errorf("error creating request: %w", err) } @@ -431,8 +503,11 @@ func (a *API) CreateCodespace(ctx context.Context, user *User, repository *Repos return nil, fmt.Errorf("error reading response body: %w", err) } - if resp.StatusCode > http.StatusAccepted { + switch { + case resp.StatusCode > http.StatusAccepted: return nil, jsonErrorResponse(b) + case resp.StatusCode == http.StatusAccepted: + return nil, errProvisioningInProgress // RPC finished before result of creation known } var response Codespace diff --git a/internal/codespaces/codespaces.go b/internal/codespaces/codespaces.go index 2933c9d8d..43809bab9 100644 --- a/internal/codespaces/codespaces.go +++ b/internal/codespaces/codespaces.go @@ -23,8 +23,8 @@ func connectionReady(codespace *api.Codespace) bool { codespace.Environment.State == api.CodespaceEnvironmentStateAvailable } -// ConnectToLiveshare creates a Live Share client and joins the Live Share session. -// It will start the Codespace if it is not already running, it will time out after 60 seconds if fails to start. +// ConnectToLiveshare waits for a Codespace to become running, +// and connects to it using a Live Share session. func ConnectToLiveshare(ctx context.Context, log logger, apiClient *api.API, userLogin, token string, codespace *api.Codespace) (*liveshare.Session, error) { var startedCodespace bool if codespace.Environment.State != api.CodespaceEnvironmentStateAvailable { @@ -61,17 +61,10 @@ func ConnectToLiveshare(ctx context.Context, log logger, apiClient *api.API, use log.Println("Connecting to your codespace...") - lsclient, err := liveshare.NewClient( - liveshare.WithConnection(liveshare.Connection{ - SessionID: codespace.Environment.Connection.SessionID, - SessionToken: codespace.Environment.Connection.SessionToken, - RelaySAS: codespace.Environment.Connection.RelaySAS, - RelayEndpoint: codespace.Environment.Connection.RelayEndpoint, - }), - ) - if err != nil { - return nil, fmt.Errorf("error creating Live Share client: %w", err) - } - - return lsclient.JoinWorkspace(ctx) + return liveshare.Connect(ctx, liveshare.Options{ + SessionID: codespace.Environment.Connection.SessionID, + SessionToken: codespace.Environment.Connection.SessionToken, + RelaySAS: codespace.Environment.Connection.RelaySAS, + RelayEndpoint: codespace.Environment.Connection.RelayEndpoint, + }) }