github.com/tilt-dev/tilt@v0.33.15-0.20240515162809-0a22ed45d8a0/internal/controllers/core/extensionrepo/reconciler.go (about)

     1  package extensionrepo
     2  
     3  import (
     4  	"context"
     5  	"fmt"
     6  	"os"
     7  	"path/filepath"
     8  	"strings"
     9  	"sync"
    10  	"time"
    11  
    12  	apierrors "k8s.io/apimachinery/pkg/api/errors"
    13  	"k8s.io/apimachinery/pkg/types"
    14  	ctrl "sigs.k8s.io/controller-runtime"
    15  	"sigs.k8s.io/controller-runtime/pkg/builder"
    16  	ctrlclient "sigs.k8s.io/controller-runtime/pkg/client"
    17  	"sigs.k8s.io/controller-runtime/pkg/reconcile"
    18  
    19  	"github.com/tilt-dev/go-get"
    20  	"github.com/tilt-dev/tilt/internal/controllers/apicmp"
    21  	"github.com/tilt-dev/tilt/internal/store"
    22  	"github.com/tilt-dev/tilt/internal/xdg"
    23  	"github.com/tilt-dev/tilt/pkg/apis"
    24  	"github.com/tilt-dev/tilt/pkg/apis/core/v1alpha1"
    25  	"github.com/tilt-dev/tilt/pkg/logger"
    26  )
    27  
    28  const tiltModulesRelDir = "tilt_modules"
    29  
    30  type Downloader interface {
    31  	DestinationPath(pkg string) string
    32  	Download(pkg string) (string, error)
    33  	HeadRef(pkg string) (string, error)
    34  	RefSync(pkg string, ref string) error
    35  }
    36  
    37  type Reconciler struct {
    38  	ctrlClient ctrlclient.Client
    39  	st         store.RStore
    40  	dlr        Downloader
    41  	mu         sync.Mutex
    42  
    43  	repoStates map[types.NamespacedName]*repoState
    44  }
    45  
    46  func (r *Reconciler) CreateBuilder(mgr ctrl.Manager) (*builder.Builder, error) {
    47  	b := ctrl.NewControllerManagedBy(mgr).
    48  		For(&v1alpha1.ExtensionRepo{})
    49  
    50  	return b, nil
    51  }
    52  
    53  func NewReconciler(ctrlClient ctrlclient.Client, st store.RStore, base xdg.Base) (*Reconciler, error) {
    54  	dlrPath, err := base.DataFile(tiltModulesRelDir)
    55  	if err != nil {
    56  		return nil, fmt.Errorf("creating extensionrepo controller: %v", err)
    57  	}
    58  	return &Reconciler{
    59  		ctrlClient: ctrlClient,
    60  		st:         st,
    61  		dlr:        get.NewDownloader(dlrPath),
    62  		repoStates: make(map[types.NamespacedName]*repoState),
    63  	}, nil
    64  }
    65  
    66  // Downloads extension repos.
    67  func (r *Reconciler) Reconcile(ctx context.Context, request reconcile.Request) (reconcile.Result, error) {
    68  	r.mu.Lock()
    69  	defer r.mu.Unlock()
    70  
    71  	nn := request.NamespacedName
    72  
    73  	var repo v1alpha1.ExtensionRepo
    74  	err := r.ctrlClient.Get(ctx, nn, &repo)
    75  	if err != nil && !apierrors.IsNotFound(err) {
    76  		return ctrl.Result{}, err
    77  	}
    78  
    79  	ctx = store.MustObjectLogHandler(ctx, r.st, &repo)
    80  
    81  	result, state, err := r.apply(ctx, nn, &repo)
    82  	if err != nil {
    83  		return ctrl.Result{}, err
    84  	}
    85  	if state == nil {
    86  		return ctrl.Result{}, nil
    87  	}
    88  
    89  	err = r.maybeUpdateStatus(ctx, &repo, state)
    90  	if err != nil {
    91  		return ctrl.Result{}, err
    92  	}
    93  	return result, nil
    94  }
    95  
    96  // Reconciles the extension repo without reading or writing from the API server.
    97  // Returns the resolved status.
    98  // Exposed for outside callers.
    99  func (r *Reconciler) ForceApply(ctx context.Context, repo *v1alpha1.ExtensionRepo) v1alpha1.ExtensionRepoStatus {
   100  	r.mu.Lock()
   101  	defer r.mu.Unlock()
   102  	nn := types.NamespacedName{Name: repo.Name, Namespace: repo.Namespace}
   103  	_, state, err := r.apply(ctx, nn, repo)
   104  	if err != nil {
   105  		return v1alpha1.ExtensionRepoStatus{Error: err.Error()}
   106  	}
   107  	if state == nil {
   108  		return v1alpha1.ExtensionRepoStatus{Error: "internal error: could not reconcile"}
   109  	}
   110  	return state.status
   111  }
   112  
   113  // Reconciles the extension repo without reading or writing from the API server.
   114  // Caller must hold the mutex.
   115  // Returns a nil state if the repo is being deleted.
   116  // Returns an error if the reconcile should be retried.
   117  func (r *Reconciler) apply(ctx context.Context, nn types.NamespacedName, repo *v1alpha1.ExtensionRepo) (ctrl.Result, *repoState, error) {
   118  	isDelete := repo.Name == "" || !repo.DeletionTimestamp.IsZero()
   119  	needsCleanup := isDelete
   120  
   121  	// If the spec has changed, clear the current repo state.
   122  	state, ok := r.repoStates[nn]
   123  	if ok && !apicmp.DeepEqual(state.spec, repo.Spec) {
   124  		needsCleanup = true
   125  	}
   126  
   127  	if needsCleanup {
   128  		// If a repo is deleted, delete it on disk.
   129  		//
   130  		// This is simple, but not accurate.
   131  		// 1) It will garbage collect too aggressively if two extension repo
   132  		//    objects point to the same URL.
   133  		// 2) If we "miss" a delete event, the repo will never get cleaned up.
   134  		//
   135  		// A "real" implementation would use the on-disk repos as the source of
   136  		// truth, and garbage collect ones with no remaining refs.
   137  		if state != nil && state.lastSuccessfulDestPath != "" {
   138  			err := os.RemoveAll(state.lastSuccessfulDestPath)
   139  			if err != nil && !os.IsNotExist(err) {
   140  				return ctrl.Result{}, nil, err
   141  			}
   142  		}
   143  		delete(r.repoStates, nn)
   144  	}
   145  
   146  	if isDelete {
   147  		return ctrl.Result{}, nil, nil
   148  	}
   149  
   150  	state, ok = r.repoStates[nn]
   151  	if !ok {
   152  		state = &repoState{spec: repo.Spec}
   153  		r.repoStates[nn] = state
   154  	}
   155  
   156  	// Keep track of the Result in case it contains Requeue instructions.
   157  	var result ctrl.Result
   158  	if strings.HasPrefix(repo.Spec.URL, "file://") {
   159  		r.reconcileFileRepo(ctx, state, strings.TrimPrefix(repo.Spec.URL, "file://"))
   160  	} else {
   161  		// Check that the URL is valid.
   162  		importPath, err := getDownloaderImportPath(repo)
   163  		if err != nil {
   164  			state.status = v1alpha1.ExtensionRepoStatus{Error: fmt.Sprintf("invalid: %v", err)}
   165  		} else {
   166  			result = r.reconcileDownloaderRepo(ctx, state, importPath)
   167  		}
   168  	}
   169  	return result, state, nil
   170  }
   171  
   172  // Reconcile a repo that lives on disk, and shouldn't otherwise be modified.
   173  func (r *Reconciler) reconcileFileRepo(ctx context.Context, state *repoState, filePath string) {
   174  	if state.spec.Ref != "" {
   175  		state.status = v1alpha1.ExtensionRepoStatus{Error: "spec.ref not supported on file:// repos"}
   176  		return
   177  	}
   178  
   179  	if !filepath.IsAbs(filePath) {
   180  		state.status = v1alpha1.ExtensionRepoStatus{
   181  			Error: fmt.Sprintf("file paths must be absolute. Url: %s", state.spec.URL),
   182  		}
   183  		return
   184  	}
   185  
   186  	info, err := os.Stat(filePath)
   187  	if err != nil {
   188  		state.status = v1alpha1.ExtensionRepoStatus{Error: fmt.Sprintf("loading: %v", err)}
   189  		return
   190  	}
   191  
   192  	if !info.IsDir() {
   193  		state.status = v1alpha1.ExtensionRepoStatus{Error: "loading: not a directory"}
   194  		return
   195  	}
   196  
   197  	timeFetched := apis.NewTime(info.ModTime())
   198  	state.status = v1alpha1.ExtensionRepoStatus{LastFetchedAt: timeFetched, Path: filePath}
   199  }
   200  
   201  // Reconcile a repo that we need to fetch remotely, and store
   202  // under ~/.tilt-dev.
   203  func (r *Reconciler) reconcileDownloaderRepo(ctx context.Context, state *repoState, importPath string) reconcile.Result {
   204  	getDlr, ok := r.dlr.(*get.Downloader)
   205  	if ok {
   206  		getDlr.Stderr = logger.Get(ctx).Writer(logger.InfoLvl)
   207  	}
   208  
   209  	destPath := r.dlr.DestinationPath(importPath)
   210  	info, err := os.Stat(destPath)
   211  	if err != nil && !os.IsNotExist(err) {
   212  		state.status = v1alpha1.ExtensionRepoStatus{Error: fmt.Sprintf("loading download destination: %v", err)}
   213  		return ctrl.Result{}
   214  	}
   215  
   216  	// If the directory exists and has already been fetched successfully during this session,
   217  	// no reconciliation is needed.
   218  	exists := err == nil
   219  	if exists && state.lastSuccessfulDestPath != "" {
   220  		return ctrl.Result{}
   221  	}
   222  
   223  	lastFetch := state.lastFetch
   224  	lastBackoff := state.backoff
   225  	if time.Since(lastFetch) < lastBackoff {
   226  		// If we're already in the middle of a backoff period, requeue.
   227  		return ctrl.Result{RequeueAfter: lastBackoff}
   228  	}
   229  
   230  	state.lastFetch = time.Now()
   231  
   232  	needsDownload := true
   233  	pinnedToVersion := state.spec.Ref != "" && state.spec.Ref != "HEAD"
   234  	if exists && pinnedToVersion {
   235  		// If an explicit ref is specified, we assume there's no reason to pull a new version.
   236  		//
   237  		// TODO(nick): Should we try to support cases where the ref can change server-side?
   238  		// e.g., a "stable" tag.
   239  		err := r.dlr.RefSync(importPath, state.spec.Ref)
   240  		if err == nil {
   241  			needsDownload = false
   242  		} else {
   243  			// TODO(nick): The more efficient thing to do here would be to
   244  			// checkout the main branch and pull. But I don't think this case will
   245  			// happen very often, so it's safer to delete and re-download.
   246  			err = os.RemoveAll(destPath)
   247  			if err != nil {
   248  				state.status = v1alpha1.ExtensionRepoStatus{
   249  					Error: fmt.Sprintf("clearing old extension repo at %s: %v", destPath, err),
   250  				}
   251  				return ctrl.Result{}
   252  			}
   253  		}
   254  	}
   255  
   256  	staleReason := ""
   257  	if needsDownload {
   258  		_, err = r.dlr.Download(importPath)
   259  		if err != nil {
   260  			if !exists {
   261  				// Delete any partial state.
   262  				_ = os.RemoveAll(destPath)
   263  
   264  				backoff := state.nextBackoff()
   265  				backoffMsg := fmt.Sprintf("download error: waiting %s before retrying. Original error: %v", backoff, err)
   266  				state.status = v1alpha1.ExtensionRepoStatus{Error: backoffMsg}
   267  				return ctrl.Result{RequeueAfter: backoff}
   268  			}
   269  			staleReason = err.Error()
   270  		}
   271  
   272  		info, err = os.Stat(destPath)
   273  		if err != nil {
   274  			state.status = v1alpha1.ExtensionRepoStatus{Error: fmt.Sprintf("verifying download destination: %v", err)}
   275  			return ctrl.Result{}
   276  		}
   277  
   278  		if state.spec.Ref != "" {
   279  			err := r.dlr.RefSync(importPath, state.spec.Ref)
   280  			if err != nil {
   281  				state.status = v1alpha1.ExtensionRepoStatus{Error: fmt.Sprintf("sync to ref %s: %v", state.spec.Ref, err)}
   282  				return ctrl.Result{}
   283  			}
   284  		}
   285  	}
   286  
   287  	ref, err := r.dlr.HeadRef(importPath)
   288  	if err != nil {
   289  		state.status = v1alpha1.ExtensionRepoStatus{Error: fmt.Sprintf("determining head: %v", err)}
   290  		return ctrl.Result{}
   291  	}
   292  
   293  	// Update the status.
   294  	state.backoff = 0
   295  	state.lastSuccessfulDestPath = destPath
   296  
   297  	timeFetched := apis.NewTime(info.ModTime())
   298  	state.status = v1alpha1.ExtensionRepoStatus{
   299  		LastFetchedAt: timeFetched,
   300  		Path:          destPath,
   301  		CheckoutRef:   ref,
   302  		StaleReason:   staleReason,
   303  	}
   304  	return ctrl.Result{}
   305  }
   306  
   307  // Loosely inspired by controllerutil's Update status algorithm.
   308  func (r *Reconciler) maybeUpdateStatus(ctx context.Context, repo *v1alpha1.ExtensionRepo, state *repoState) error {
   309  	if apicmp.DeepEqual(repo.Status, state.status) {
   310  		return nil
   311  	}
   312  
   313  	oldStaleReason := repo.Status.StaleReason
   314  	newStaleReason := state.status.StaleReason
   315  	oldError := repo.Status.Error
   316  	newError := state.status.Error
   317  	update := repo.DeepCopy()
   318  	update.Status = *(state.status.DeepCopy())
   319  
   320  	err := r.ctrlClient.Status().Update(ctx, update)
   321  	if err != nil {
   322  		return err
   323  	}
   324  
   325  	if newStaleReason != "" && newStaleReason != oldStaleReason {
   326  		logger.Get(ctx).Infof("extensionrepo %s: may be stale: %s", repo.Name, newStaleReason)
   327  	}
   328  
   329  	if newError != "" && oldError != newError {
   330  		logger.Get(ctx).Errorf("extensionrepo %s: %s", repo.Name, newError)
   331  	}
   332  
   333  	return err
   334  }
   335  
   336  func getDownloaderImportPath(repo *v1alpha1.ExtensionRepo) (string, error) {
   337  	// TODO(nick): Add file URLs
   338  	url := repo.Spec.URL
   339  	if strings.HasPrefix(url, "https://") {
   340  		return strings.TrimPrefix(url, "https://"), nil
   341  	}
   342  	if strings.HasPrefix(url, "http://") {
   343  		return strings.TrimPrefix(url, "http://"), nil
   344  	}
   345  	return "", fmt.Errorf("URL must start with 'https://': %v", url)
   346  }
   347  
   348  type repoState struct {
   349  	spec                   v1alpha1.ExtensionRepoSpec
   350  	lastFetch              time.Time
   351  	backoff                time.Duration
   352  	lastSuccessfulDestPath string
   353  
   354  	status v1alpha1.ExtensionRepoStatus
   355  }
   356  
   357  // Step up the backoff after an error.
   358  func (s *repoState) nextBackoff() time.Duration {
   359  	backoff := s.backoff
   360  	if backoff == 0 {
   361  		backoff = 5 * time.Second
   362  	} else {
   363  		backoff = 2 * backoff
   364  	}
   365  	s.backoff = backoff
   366  	return backoff
   367  }