github.com/tilt-dev/tilt@v0.33.15-0.20240515162809-0a22ed45d8a0/internal/cloud/cloud_status_manager.go (about)

     1  package cloud
     2  
     3  import (
     4  	"bytes"
     5  	"context"
     6  	"encoding/json"
     7  	"io"
     8  	"net/http"
     9  	"sync"
    10  	"time"
    11  
    12  	"github.com/jonboulle/clockwork"
    13  
    14  	"github.com/tilt-dev/tilt/internal/cloud/cloudurl"
    15  	"github.com/tilt-dev/tilt/internal/store"
    16  	"github.com/tilt-dev/tilt/internal/token"
    17  	"github.com/tilt-dev/tilt/pkg/logger"
    18  	"github.com/tilt-dev/tilt/pkg/model"
    19  )
    20  
    21  // to avoid infinitely resubmitting requests on error
    22  const timeoutAfterError = 5 * time.Minute
    23  
    24  // how frequently we'll refresh cloud status, even if nothing changes
    25  const refreshPeriod = time.Hour
    26  
    27  func NewStatusManager(client HttpClient, clock clockwork.Clock) *CloudStatusManager {
    28  	return &CloudStatusManager{client: client, clock: clock}
    29  }
    30  
    31  // if any of these fields change, we know we need to do a fresh lookup
    32  type statusRequestKey struct {
    33  	tiltToken token.Token
    34  	teamID    string
    35  	version   model.TiltBuild
    36  }
    37  
    38  type CloudStatusManager struct {
    39  	client HttpClient
    40  	clock  clockwork.Clock
    41  
    42  	mu sync.Mutex
    43  
    44  	lastErrorTime          time.Time
    45  	currentlyMakingRequest bool
    46  
    47  	lastRequestKey       statusRequestKey
    48  	lastSuccessfulLookup time.Time
    49  }
    50  
    51  func ProvideHttpClient() HttpClient {
    52  	return http.DefaultClient
    53  }
    54  
    55  type HttpClient interface {
    56  	Do(req *http.Request) (*http.Response, error)
    57  }
    58  
    59  type whoAmIResponse struct {
    60  	SuggestedTiltVersion string
    61  }
    62  
    63  func (c *CloudStatusManager) error() {
    64  	c.mu.Lock()
    65  	c.lastErrorTime = c.clock.Now()
    66  	c.mu.Unlock()
    67  }
    68  
    69  func (c *CloudStatusManager) CheckStatus(ctx context.Context, st store.RStore, cloudAddress string, requestKey statusRequestKey) {
    70  	c.mu.Lock()
    71  	c.currentlyMakingRequest = true
    72  	c.mu.Unlock()
    73  
    74  	defer func() {
    75  		c.mu.Lock()
    76  		c.currentlyMakingRequest = false
    77  		c.mu.Unlock()
    78  	}()
    79  
    80  	u := cloudurl.URL(cloudAddress)
    81  	u.Path = "/api/whoami"
    82  
    83  	body := &bytes.Buffer{}
    84  	req, err := http.NewRequest("GET", u.String(), body)
    85  	if err != nil {
    86  		logger.Get(ctx).Debugf("error making whoami request: %v", err)
    87  		c.error()
    88  		return
    89  	}
    90  
    91  	resp, err := c.client.Do(req)
    92  	if err != nil {
    93  		logger.Get(ctx).Debugf("error checking tilt cloud status: %v", err)
    94  		c.error()
    95  		return
    96  	}
    97  
    98  	if resp.StatusCode != http.StatusOK {
    99  		body, err := io.ReadAll(resp.Body)
   100  		if err != nil {
   101  			logger.Get(ctx).Debugf("tilt cloud status request failed with status %d. error reading response body: %v", resp.StatusCode, err)
   102  			c.error()
   103  			return
   104  		}
   105  		logger.Get(ctx).Debugf("error checking tilt cloud status: code: %d, message: %s", resp.StatusCode, string(body))
   106  		c.error()
   107  		return
   108  	}
   109  
   110  	responseBody, err := io.ReadAll(resp.Body)
   111  	if err != nil {
   112  		logger.Get(ctx).Debugf("error reading response body: %v", err)
   113  		c.error()
   114  		return
   115  	}
   116  	r := whoAmIResponse{}
   117  	err = json.NewDecoder(bytes.NewReader(responseBody)).Decode(&r)
   118  	if err != nil {
   119  		logger.Get(ctx).Debugf("error decoding tilt whoami response '%s': %v", string(responseBody), err)
   120  		c.error()
   121  		return
   122  	}
   123  
   124  	c.mu.Lock()
   125  	c.lastRequestKey = requestKey
   126  	c.lastSuccessfulLookup = c.clock.Now()
   127  	c.lastErrorTime = time.Time{}
   128  	c.mu.Unlock()
   129  
   130  	st.Dispatch(store.TiltCloudStatusReceivedAction{
   131  		SuggestedTiltVersion: r.SuggestedTiltVersion,
   132  	})
   133  }
   134  
   135  func (c *CloudStatusManager) needsLookup(requestKey statusRequestKey) bool {
   136  	return c.lastSuccessfulLookup.IsZero() ||
   137  		c.lastSuccessfulLookup.Add(refreshPeriod).Before(c.clock.Now()) ||
   138  		requestKey != c.lastRequestKey
   139  }
   140  
   141  func (c *CloudStatusManager) OnChange(ctx context.Context, st store.RStore, _ store.ChangeSummary) error {
   142  	state := st.RLockState()
   143  	defer st.RUnlockState()
   144  
   145  	c.mu.Lock()
   146  	lastErrorTime := c.lastErrorTime
   147  	currentlyMakingRequest := c.currentlyMakingRequest
   148  	requestKey := statusRequestKey{teamID: state.TeamID, tiltToken: state.Token, version: state.TiltBuildInfo}
   149  	needsLookup := c.needsLookup(requestKey)
   150  	c.mu.Unlock()
   151  
   152  	// c.currentlyMakingRequest is a bit of a race condition here:
   153  	// 1. start making request that's going to return TokenKnownUnregistered = true
   154  	// 2. before request finishes, web ui triggers refresh, setting TokenKnownUnregistered = false
   155  	// 3. request started in (1) finishes, sets TokenKnownUnregistered = true
   156  	// we never make a request post-(2), where the token was registered
   157  	// This is mitigated by - a) the window between (1) and (3) is small, and b) the user can just click refresh again
   158  	allowedToPerformLookup := !time.Now().Before(lastErrorTime.Add(timeoutAfterError)) && !currentlyMakingRequest
   159  
   160  	if needsLookup && allowedToPerformLookup {
   161  		go c.CheckStatus(ctx, st, state.CloudAddress, requestKey)
   162  		return nil
   163  	}
   164  	return nil
   165  }