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

     1  package extension
     2  
     3  import (
     4  	"context"
     5  	"fmt"
     6  	"os"
     7  	"path/filepath"
     8  	"strings"
     9  	"sync"
    10  
    11  	apierrors "k8s.io/apimachinery/pkg/api/errors"
    12  	"k8s.io/apimachinery/pkg/runtime"
    13  	"k8s.io/apimachinery/pkg/types"
    14  	ctrl "sigs.k8s.io/controller-runtime"
    15  	"sigs.k8s.io/controller-runtime/pkg/builder"
    16  	"sigs.k8s.io/controller-runtime/pkg/client"
    17  	ctrlclient "sigs.k8s.io/controller-runtime/pkg/client"
    18  	"sigs.k8s.io/controller-runtime/pkg/handler"
    19  	"sigs.k8s.io/controller-runtime/pkg/reconcile"
    20  
    21  	"github.com/tilt-dev/tilt/internal/analytics"
    22  	"github.com/tilt-dev/tilt/internal/controllers/apicmp"
    23  	"github.com/tilt-dev/tilt/internal/controllers/indexer"
    24  	"github.com/tilt-dev/tilt/internal/ospath"
    25  	"github.com/tilt-dev/tilt/pkg/apis/core/v1alpha1"
    26  	"github.com/tilt-dev/tilt/pkg/logger"
    27  )
    28  
    29  type Reconciler struct {
    30  	ctrlClient ctrlclient.Client
    31  	indexer    *indexer.Indexer
    32  	mu         sync.Mutex
    33  	analytics  *analytics.TiltAnalytics
    34  }
    35  
    36  func (r *Reconciler) CreateBuilder(mgr ctrl.Manager) (*builder.Builder, error) {
    37  	b := ctrl.NewControllerManagedBy(mgr).
    38  		For(&v1alpha1.Extension{}).
    39  		Watches(&v1alpha1.ExtensionRepo{},
    40  			handler.EnqueueRequestsFromMapFunc(r.indexer.Enqueue))
    41  
    42  	return b, nil
    43  }
    44  
    45  func NewReconciler(ctrlClient ctrlclient.Client, scheme *runtime.Scheme, analytics *analytics.TiltAnalytics) *Reconciler {
    46  	return &Reconciler{
    47  		ctrlClient: ctrlClient,
    48  		indexer:    indexer.NewIndexer(scheme, indexExtension),
    49  		analytics:  analytics,
    50  	}
    51  }
    52  
    53  // Verifies extension paths.
    54  func (r *Reconciler) Reconcile(ctx context.Context, request reconcile.Request) (reconcile.Result, error) {
    55  	r.mu.Lock()
    56  	defer r.mu.Unlock()
    57  
    58  	nn := request.NamespacedName
    59  
    60  	var ext v1alpha1.Extension
    61  	err := r.ctrlClient.Get(ctx, nn, &ext)
    62  	r.indexer.OnReconcile(nn, &ext)
    63  	if err != nil && !apierrors.IsNotFound(err) {
    64  		return ctrl.Result{}, err
    65  	}
    66  
    67  	// Cleanup tiltfile loads if an extension is deleted.
    68  	if apierrors.IsNotFound(err) || !ext.ObjectMeta.DeletionTimestamp.IsZero() {
    69  		err := r.manageOwnedTiltfile(ctx, nn, nil)
    70  		if err != nil {
    71  			return ctrl.Result{}, err
    72  		}
    73  		return ctrl.Result{}, nil
    74  	}
    75  
    76  	var repo v1alpha1.ExtensionRepo
    77  	err = r.ctrlClient.Get(ctx, types.NamespacedName{Name: ext.Spec.RepoName}, &repo)
    78  	if err != nil && !apierrors.IsNotFound(err) {
    79  		return ctrl.Result{}, err
    80  	}
    81  
    82  	newStatus := r.apply(&ext, &repo)
    83  
    84  	update, changed, err := r.maybeUpdateStatus(ctx, &ext, newStatus)
    85  	if err != nil {
    86  		return ctrl.Result{}, err
    87  	}
    88  
    89  	// Always manage the child objects, even if the user-visible status didn't change,
    90  	// because there might be internal state we need to propagate.
    91  	err = r.manageOwnedTiltfile(ctx, types.NamespacedName{Name: ext.Name}, update)
    92  	if err != nil {
    93  		return ctrl.Result{}, err
    94  	}
    95  
    96  	if changed && update.Status.Error == "" {
    97  		repoType := "http"
    98  		if strings.HasPrefix(repo.Spec.URL, "file://") {
    99  			repoType = "file"
   100  		}
   101  		r.analytics.Incr("api.extension.load", map[string]string{
   102  			"ext_path":      ext.Spec.RepoPath,
   103  			"repo_url_hash": analytics.HashSHA1(repo.Spec.URL),
   104  			"repo_type":     repoType,
   105  		})
   106  	}
   107  
   108  	return ctrl.Result{}, nil
   109  }
   110  
   111  // Reconciles the extension without reading or writing from the API server.
   112  // Returns the resolved status.
   113  // Exposed for outside callers.
   114  func (r *Reconciler) ForceApply(ext *v1alpha1.Extension, repo *v1alpha1.ExtensionRepo) v1alpha1.ExtensionStatus {
   115  	r.mu.Lock()
   116  	defer r.mu.Unlock()
   117  	return r.apply(ext, repo)
   118  }
   119  
   120  func (r *Reconciler) apply(ext *v1alpha1.Extension, repo *v1alpha1.ExtensionRepo) v1alpha1.ExtensionStatus {
   121  	if repo.Name == "" {
   122  		return v1alpha1.ExtensionStatus{Error: fmt.Sprintf("extension repo not found: %s", ext.Spec.RepoName)}
   123  	}
   124  
   125  	if repo.Status.Path == "" {
   126  		return v1alpha1.ExtensionStatus{Error: fmt.Sprintf("extension repo not loaded: %s", ext.Spec.RepoName)}
   127  	}
   128  
   129  	absPath := filepath.Join(repo.Status.Path, ext.Spec.RepoPath, "Tiltfile")
   130  
   131  	// Make sure the user isn't trying to use path tricks to "escape" the repo.
   132  	if !ospath.IsChild(repo.Status.Path, absPath) {
   133  		return v1alpha1.ExtensionStatus{Error: fmt.Sprintf("invalid repo path: %s", ext.Spec.RepoPath)}
   134  	}
   135  
   136  	info, err := os.Stat(absPath)
   137  	if err != nil || !info.Mode().IsRegular() {
   138  		return v1alpha1.ExtensionStatus{Error: fmt.Sprintf("no extension tiltfile found at %s", absPath)}
   139  	}
   140  
   141  	return v1alpha1.ExtensionStatus{Path: absPath}
   142  }
   143  
   144  // Update the status. Returns true if the status changed.
   145  func (r *Reconciler) maybeUpdateStatus(ctx context.Context, obj *v1alpha1.Extension, newStatus v1alpha1.ExtensionStatus) (*v1alpha1.Extension, bool, error) {
   146  	if apicmp.DeepEqual(obj.Status, newStatus) {
   147  		return obj, false, nil
   148  	}
   149  
   150  	oldError := obj.Status.Error
   151  	newError := newStatus.Error
   152  	update := obj.DeepCopy()
   153  	update.Status = *(newStatus.DeepCopy())
   154  
   155  	err := r.ctrlClient.Status().Update(ctx, update)
   156  	if err != nil {
   157  		return obj, false, err
   158  	}
   159  
   160  	isLoggedError := newError != "" &&
   161  		!strings.HasPrefix(newError, "extension repo not loaded") &&
   162  		!strings.HasPrefix(newError, "extension repo not found")
   163  	if isLoggedError && oldError != newError {
   164  		logger.Get(ctx).Errorf("extension %s: %s", obj.Name, newError)
   165  	}
   166  	return update, true, err
   167  }
   168  
   169  // Find all the objects we need to watch based on the extension spec.
   170  func indexExtension(obj client.Object) []indexer.Key {
   171  	result := []indexer.Key{}
   172  	ext := obj.(*v1alpha1.Extension)
   173  	if ext.Spec.RepoName != "" {
   174  		repoGVK := v1alpha1.SchemeGroupVersion.WithKind("ExtensionRepo")
   175  		result = append(result, indexer.Key{
   176  			Name: types.NamespacedName{Name: ext.Spec.RepoName},
   177  			GVK:  repoGVK,
   178  		})
   179  	}
   180  	return result
   181  }