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 }