cli/internal/codespaces/codespaces.go
2021-09-22 09:40:45 -04:00

149 lines
4.8 KiB
Go

package codespaces
import (
"context"
"errors"
"fmt"
"time"
"github.com/github/ghcs/internal/api"
"github.com/github/go-liveshare"
)
type logger interface {
Print(v ...interface{}) (int, error)
Println(v ...interface{}) (int, 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
}
// 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.
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 {
startedCodespace = true
log.Print("Starting your codespace...")
if err := apiClient.StartCodespace(ctx, token, codespace); err != nil {
return nil, fmt.Errorf("error starting codespace: %w", err)
}
}
for retries := 0; !connectionReady(codespace); retries++ {
if retries > 1 {
if retries%2 == 0 {
log.Print(".")
}
time.Sleep(1 * time.Second)
}
if retries == 30 {
return nil, errors.New("timed out while waiting for the codespace to start")
}
var err error
codespace, err = apiClient.GetCodespace(ctx, token, userLogin, codespace.Name)
if err != nil {
return nil, fmt.Errorf("error getting codespace: %w", err)
}
}
if startedCodespace {
fmt.Print("\n")
}
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)
}
type apiClient interface {
CreateCodespace(ctx context.Context, user *api.User, repo *api.Repository, machine, branch, location string) (*api.Codespace, error)
GetCodespaceToken(ctx context.Context, userLogin, codespaceName string) (string, error)
GetCodespace(ctx context.Context, token, userLogin, codespaceName string) (*api.Codespace, error)
}
// ProvisionParams are the required parameters for provisioning a Codespace.
type ProvisionParams struct {
User *api.User
Repository *api.Repository
Branch, Machine, Location string
}
// Provision creates a codespace with the given parameters and handles polling in the case
// of initial creation failures.
func Provision(ctx context.Context, log logger, client apiClient, params *ProvisionParams) (*api.Codespace, error) {
codespace, err := client.CreateCodespace(
ctx, params.User, params.Repository, params.Machine, params.Branch, params.Location,
)
if err != nil {
// This error is returned by the API when the initial creation fails with a retryable error.
// A retryable error means that GitHub will retry to re-create Codespace and clients should poll
// the API and attempt to fetch the Codespace for the next two minutes.
if err == api.ErrCreateAsyncRetry {
log.Print("Switching to async provisioning...")
pollTimeout := 2 * time.Minute
pollInterval := 1 * time.Second
codespace, err = pollForCodespace(ctx, client, log, pollTimeout, pollInterval, params.User.Login, codespace.Name)
log.Print("\n")
if err != nil {
return nil, fmt.Errorf("error creating codespace with async provisioning: %s: %w", codespace.Name, err)
}
}
return nil, err
}
return codespace, nil
}
// pollForCodespace polls the Codespaces GET endpoint on a given interval for a specified duration.
// If it succeeds at fetching the codespace, we consider the codespace provisioned.
func pollForCodespace(ctx context.Context, client apiClient, log logger, duration, interval time.Duration, user, name string) (*api.Codespace, error) {
ctx, cancel := context.WithTimeout(ctx, duration)
defer cancel()
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-ticker.C:
log.Print(".")
token, err := client.GetCodespaceToken(ctx, user, name)
if err != nil {
if err == api.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)
}
return client.GetCodespace(ctx, token, user, name)
}
}
}