From fe6987f464d2f638359553dd425e778ed88462dd Mon Sep 17 00:00:00 2001 From: Greg Soltis Date: Wed, 20 Apr 2022 11:54:41 -0700 Subject: [PATCH 1/2] Check Remote Caching status before linking --- cli/internal/client/client.go | 72 ++++++++++++++++++++-- cli/internal/login/link.go | 112 +++++++++++++++++++++++++++------- cli/internal/util/status.go | 31 ++++++++++ 3 files changed, 189 insertions(+), 26 deletions(-) create mode 100644 cli/internal/util/status.go diff --git a/cli/internal/client/client.go b/cli/internal/client/client.go index 0d40542c6e0e6..cf7687b7af151 100644 --- a/cli/internal/client/client.go +++ b/cli/internal/client/client.go @@ -17,6 +17,7 @@ import ( "github.com/hashicorp/go-hclog" "github.com/hashicorp/go-retryablehttp" + "github.com/vercel/turborepo/cli/internal/util" ) type ApiClient struct { @@ -317,13 +318,32 @@ func (c *ApiClient) addTeamParam(params *url.Values) { } } +// Membership is the relationship between the logged-in user and a particular team +type Membership struct { + Role string `json:"role"` +} + // Team is a Vercel Team object type Team struct { - ID string `json:"id,omitempty"` - Slug string `json:"slug,omitempty"` - Name string `json:"name,omitempty"` - CreatedAt int `json:"createdAt,omitempty"` - Created string `json:"created,omitempty"` + ID string `json:"id,omitempty"` + Slug string `json:"slug,omitempty"` + Name string `json:"name,omitempty"` + CreatedAt int `json:"createdAt,omitempty"` + Created string `json:"created,omitempty"` + Membership Membership `json:"membership"` +} + +// IsOwner returns true if this Team data was fetched by an owner of the team +func (t *Team) IsOwner() bool { + return t.Membership.Role == "OWNER" +} + +func TeamFromUser(user *User) Team { + return Team{ + ID: user.ID, + Name: user.Name, + Membership: Membership{Role: "OWNER"}, + } } // Pagination is a Vercel pagination object @@ -417,6 +437,48 @@ func (c *ApiClient) GetUser() (*UserResponse, error) { return userResponse, nil } +// statusResponse is the server response from /artifacts/status +type statusResponse struct { + Status string `json:"status"` +} + +// GetCachingStatus returns the server's perspective on whether or not remove caching +// requests will be allowed. +func (c *ApiClient) GetCachingStatus(teamID string) (util.CachingStatus, error) { + req, err := retryablehttp.NewRequest(http.MethodGet, c.makeUrl("/v8/artifacts/status"), nil) + if err != nil { + return util.CachingStatusDisabled, err + } + req.Header.Set("User-Agent", c.UserAgent()) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+c.Token) + resp, err := c.HttpClient.Do(req) + if err != nil { + return util.CachingStatusDisabled, err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + b, err := ioutil.ReadAll(resp.Body) + var responseText string + if err != nil { + responseText = fmt.Sprintf("failed to read response: %v", err) + } else { + responseText = string(b) + } + return util.CachingStatusDisabled, fmt.Errorf("failed to get caching status (%v): %s", resp.StatusCode, responseText) + } + body, err := ioutil.ReadAll(resp.Body) + if err != nil { + return util.CachingStatusDisabled, fmt.Errorf("failed to read JSN response: %v", err) + } + statusResponse := statusResponse{} + err = json.Unmarshal(body, &statusResponse) + if err != nil { + return util.CachingStatusDisabled, fmt.Errorf("failed to read JSON response: %v", string(body)) + } + return util.CachingStatusFromString(statusResponse.Status) +} + type verificationResponse struct { Token string `json:"token"` Email string `json:"email"` diff --git a/cli/internal/login/link.go b/cli/internal/login/link.go index 09ba37fdf9960..b378ef2507aea 100644 --- a/cli/internal/login/link.go +++ b/cli/internal/login/link.go @@ -14,6 +14,7 @@ import ( "github.com/vercel/turborepo/cli/internal/fs" "github.com/vercel/turborepo/cli/internal/ui" "github.com/vercel/turborepo/cli/internal/util" + "github.com/vercel/turborepo/cli/internal/util/browser" "github.com/AlecAivazis/survey/v2" "github.com/fatih/color" @@ -28,13 +29,15 @@ type LinkCommand struct { } type link struct { - ui cli.Ui - logger hclog.Logger - modifyGitIgnore bool - apiURL string - apiClient linkAPIClient - promptSetup func(location string) (bool, error) - promptTeam func(teams []string) (string, error) + ui cli.Ui + logger hclog.Logger + modifyGitIgnore bool + apiURL string + apiClient linkAPIClient + promptSetup func(location string) (bool, error) + promptTeam func(teams []string) (string, error) + promptEnableCaching func() (bool, error) + openBrowser func(url string) error } type linkAPIClient interface { @@ -42,6 +45,7 @@ type linkAPIClient interface { GetTeams() (*client.TeamsResponse, error) GetUser() (*client.UserResponse, error) SetTeamID(teamID string) + GetCachingStatus(teamID string) (util.CachingStatus, error) } func getCmd(config *config.Config, ui cli.Ui) *cobra.Command { @@ -51,18 +55,26 @@ func getCmd(config *config.Config, ui cli.Ui) *cobra.Command { Short: "Link your local directory to a Vercel organization and enable remote caching.", RunE: func(cmd *cobra.Command, args []string) error { link := &link{ - ui: ui, - logger: config.Logger, - modifyGitIgnore: !dontModifyGitIgnore, - apiURL: config.ApiUrl, - apiClient: config.ApiClient, - promptSetup: promptSetup, - promptTeam: promptTeam, + ui: ui, + logger: config.Logger, + modifyGitIgnore: !dontModifyGitIgnore, + apiURL: config.ApiUrl, + apiClient: config.ApiClient, + promptSetup: promptSetup, + promptTeam: promptTeam, + promptEnableCaching: promptEnableCaching, + openBrowser: browser.OpenBrowser, } err := link.run() if err != nil { if errors.Is(err, errUserCanceled) { ui.Info("Canceled. Turborepo not set up.") + } else if errors.Is(err, errTryAfterEnable) { + ui.Info("Please run 'turbo link' again after remote caching has been enabled") + } else if errors.Is(err, errNeedCachingEnabled) { + ui.Info("Please contact your account owner to enable remote caching on Vercel.") + } else if errors.Is(err, errOverage) { + ui.Warn("TODO: hobby error message") } else { link.logError(err) } @@ -98,7 +110,12 @@ func (c *LinkCommand) Run(args []string) int { return 0 } -var errUserCanceled = errors.New("Canceled") +var ( + errUserCanceled = errors.New("canceled") + errOverage = errors.New("usage limit") + errNeedCachingEnabled = errors.New("caching not enabled") + errTryAfterEnable = errors.New("link after enabling caching") +) func (l *link) run() error { dir, err := homedir.Dir() @@ -157,13 +174,11 @@ func (l *link) run() error { if chosenTeamName == "" { return errUserCanceled } + isUser := (chosenTeamName == userResponse.User.Name) || (chosenTeamName == userResponse.User.Username) var chosenTeam client.Team - if (chosenTeamName == userResponse.User.Name) || (chosenTeamName == userResponse.User.Username) { - chosenTeam = client.Team{ - ID: userResponse.User.ID, - Name: userResponse.User.Name, - Slug: userResponse.User.Username, - } + var accountID string + if isUser { + accountID = userResponse.User.ID } else { for _, team := range teamsResponse.Teams { if team.Name == chosenTeamName { @@ -171,7 +186,43 @@ func (l *link) run() error { break } } + accountID = chosenTeam.ID + } + + cachingStatus, err := l.apiClient.GetCachingStatus(accountID) + if err != nil { + return err } + switch cachingStatus { + case util.CachingStatusDisabled: + if isUser || chosenTeam.IsOwner() { + shouldEnable, err := l.promptEnableCaching() + if err != nil { + return err + } + if shouldEnable { + var url string + if isUser { + url = "https://vercel.com/account/billing" + } else { + url = fmt.Sprintf("https://vercel.com/teams/%v/settings/billing", chosenTeam.Slug) + } + err = l.openBrowser(url) + if err != nil { + l.ui.Warn(fmt.Sprintf("Failed to open browser. Please visit %v to enable Remote Caching", url)) + } else { + l.ui.Info(fmt.Sprintf("Visit %v in your browser to enable Remote Caching", url)) + } + } + return errTryAfterEnable + } + return errNeedCachingEnabled + case util.CachingStatusOverLimit: + return errOverage + case util.CachingStatusEnabled: + default: + } + fs.EnsureDir(filepath.Join(".turbo", "config.json")) err = config.WriteRepoConfigFile(&config.TurborepoConfig{ TeamId: chosenTeam.ID, @@ -203,6 +254,25 @@ func (l *link) logError(err error) { l.ui.Error(fmt.Sprintf("%s%s", ui.ERROR_PREFIX, color.RedString(" %v", err))) } +func promptEnableCaching() (bool, error) { + shouldEnable := false + err := survey.AskOne( + &survey.Confirm{ + Default: true, + Message: util.Sprintf("Remote Caching was previously disabled for this team. Would you like to enable it now?"), + }, + &shouldEnable, + survey.WithValidator(survey.Required), + survey.WithIcons(func(icons *survey.IconSet) { + icons.Question.Format = "gray+hb" + }), + ) + if err != nil { + return false, err + } + return shouldEnable, nil +} + func promptSetup(location string) (bool, error) { shouldSetup := true err := survey.AskOne( diff --git a/cli/internal/util/status.go b/cli/internal/util/status.go new file mode 100644 index 0000000000000..ede6c5690fafd --- /dev/null +++ b/cli/internal/util/status.go @@ -0,0 +1,31 @@ +package util + +import "fmt" + +// CachingStatus represents the api server's perspective +// on whether remote caching should be allowed +type CachingStatus int + +const ( + // CachingStatusDisabled indicates that the server will not accept or serve artifacts + CachingStatusDisabled CachingStatus = iota + // CachingStatusEnabled indicates that the server will accept and serve artifacts + CachingStatusEnabled + // CachingStatusOverLimit indicates that a usage limit has been hit and the + // server will temporarily not accept or server artifacts + CachingStatusOverLimit +) + +// CachingStatusFromString parses a raw string to a caching status enum value +func CachingStatusFromString(raw string) (CachingStatus, error) { + switch raw { + case "disabled": + return CachingStatusDisabled, nil + case "enabled": + return CachingStatusEnabled, nil + case "over_limit": + return CachingStatusOverLimit, nil + default: + return CachingStatusDisabled, fmt.Errorf("unknown caching status: %v", raw) + } +} From b47ac3179ee4281d05edd41da3704244366ca224 Mon Sep 17 00:00:00 2001 From: Greg Soltis Date: Wed, 20 Apr 2022 21:41:32 -0700 Subject: [PATCH 2/2] Lint --- cli/internal/client/client.go | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/cli/internal/client/client.go b/cli/internal/client/client.go index cf7687b7af151..b034955cc50a8 100644 --- a/cli/internal/client/client.go +++ b/cli/internal/client/client.go @@ -338,14 +338,6 @@ func (t *Team) IsOwner() bool { return t.Membership.Role == "OWNER" } -func TeamFromUser(user *User) Team { - return Team{ - ID: user.ID, - Name: user.Name, - Membership: Membership{Role: "OWNER"}, - } -} - // Pagination is a Vercel pagination object type Pagination struct { Count int `json:"count,omitempty"` @@ -456,7 +448,9 @@ func (c *ApiClient) GetCachingStatus(teamID string) (util.CachingStatus, error) if err != nil { return util.CachingStatusDisabled, err } - defer resp.Body.Close() + // Explicitly ignore the error from closing the response body. We don't need + // to fail the method if we fail to close the response. + defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { b, err := ioutil.ReadAll(resp.Body) var responseText string