github.com/tilt-dev/tilt@v0.33.15-0.20240515162809-0a22ed45d8a0/internal/controllers/core/filewatch/controller.go (about) 1 /* 2 Licensed under the Apache License, Version 2.0 (the "License"); 3 you may not use this file except in compliance with the License. 4 You may obtain a copy of the License at 5 http://www.apache.org/licenses/LICENSE-2.0 6 Unless required by applicable law or agreed to in writing, software 7 distributed under the License is distributed on an "AS IS" BASIS, 8 WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 9 See the License for the specific language governing permissions and 10 limitations under the License. 11 */ 12 13 package filewatch 14 15 import ( 16 "context" 17 "fmt" 18 "sync" 19 "time" 20 21 "github.com/jonboulle/clockwork" 22 apierrors "k8s.io/apimachinery/pkg/api/errors" 23 "k8s.io/apimachinery/pkg/runtime" 24 "k8s.io/apimachinery/pkg/types" 25 ctrl "sigs.k8s.io/controller-runtime" 26 "sigs.k8s.io/controller-runtime/pkg/builder" 27 ctrlclient "sigs.k8s.io/controller-runtime/pkg/client" 28 "sigs.k8s.io/controller-runtime/pkg/handler" 29 30 "github.com/tilt-dev/fsnotify" 31 "github.com/tilt-dev/tilt/internal/controllers/apicmp" 32 "github.com/tilt-dev/tilt/internal/controllers/apis/configmap" 33 "github.com/tilt-dev/tilt/internal/controllers/core/filewatch/fsevent" 34 "github.com/tilt-dev/tilt/internal/controllers/indexer" 35 "github.com/tilt-dev/tilt/internal/ignore" 36 "github.com/tilt-dev/tilt/internal/store" 37 "github.com/tilt-dev/tilt/internal/store/filewatches" 38 "github.com/tilt-dev/tilt/internal/watch" 39 "github.com/tilt-dev/tilt/pkg/apis" 40 "github.com/tilt-dev/tilt/pkg/apis/core/v1alpha1" 41 "github.com/tilt-dev/tilt/pkg/logger" 42 ) 43 44 // Controller reconciles a FileWatch object 45 type Controller struct { 46 ctrlclient.Client 47 Store store.RStore 48 49 targetWatches map[types.NamespacedName]*watcher 50 fsWatcherMaker fsevent.WatcherMaker 51 timerMaker fsevent.TimerMaker 52 mu sync.Mutex 53 clock clockwork.Clock 54 indexer *indexer.Indexer 55 requeuer *indexer.Requeuer 56 } 57 58 func NewController(client ctrlclient.Client, store store.RStore, fsWatcherMaker fsevent.WatcherMaker, timerMaker fsevent.TimerMaker, scheme *runtime.Scheme, clock clockwork.Clock) *Controller { 59 return &Controller{ 60 Client: client, 61 Store: store, 62 targetWatches: make(map[types.NamespacedName]*watcher), 63 fsWatcherMaker: fsWatcherMaker, 64 timerMaker: timerMaker, 65 indexer: indexer.NewIndexer(scheme, indexFw), 66 requeuer: indexer.NewRequeuer(), 67 clock: clock, 68 } 69 } 70 71 func (c *Controller) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { 72 c.mu.Lock() 73 defer c.mu.Unlock() 74 existing, hasExisting := c.targetWatches[req.NamespacedName] 75 76 var fw v1alpha1.FileWatch 77 err := c.Client.Get(ctx, req.NamespacedName, &fw) 78 79 c.indexer.OnReconcile(req.NamespacedName, &fw) 80 81 if err != nil && !apierrors.IsNotFound(err) { 82 return ctrl.Result{}, err 83 } 84 85 if apierrors.IsNotFound(err) || !fw.ObjectMeta.DeletionTimestamp.IsZero() { 86 if hasExisting { 87 existing.cleanupWatch(ctx) 88 c.removeWatch(existing) 89 } 90 c.Store.Dispatch(filewatches.NewFileWatchDeleteAction(req.NamespacedName.Name)) 91 return ctrl.Result{}, nil 92 } 93 94 // The apiserver is the source of truth, and will ensure the engine state is up to date. 95 c.Store.Dispatch(filewatches.NewFileWatchUpsertAction(&fw)) 96 97 ctx = store.MustObjectLogHandler(ctx, c.Store, &fw) 98 99 // Get configmap's disable status 100 disableStatus, err := configmap.MaybeNewDisableStatus(ctx, c.Client, fw.Spec.DisableSource, fw.Status.DisableStatus) 101 if err != nil { 102 return ctrl.Result{}, err 103 } 104 105 // Clean up existing filewatches if it's disabled 106 result := ctrl.Result{} 107 if disableStatus.State == v1alpha1.DisableStateDisabled { 108 if hasExisting { 109 existing.cleanupWatch(ctx) 110 c.removeWatch(existing) 111 } 112 } else { 113 // Determine if we the filewatch needs to be refreshed. 114 shouldRestart := !hasExisting || !apicmp.DeepEqual(existing.spec, fw.Spec) 115 if hasExisting && !shouldRestart { 116 shouldRestart, result = existing.shouldRestart() 117 } 118 119 if shouldRestart { 120 c.addOrReplace(ctx, req.NamespacedName, &fw) 121 } 122 } 123 124 watch, ok := c.targetWatches[req.NamespacedName] 125 status := &v1alpha1.FileWatchStatus{DisableStatus: disableStatus} 126 if ok { 127 status = watch.copyStatus() 128 status.DisableStatus = disableStatus 129 } 130 131 err = c.maybeUpdateObjectStatus(ctx, &fw, status) 132 if err != nil { 133 return ctrl.Result{}, err 134 } 135 136 return result, nil 137 } 138 139 func (c *Controller) maybeUpdateObjectStatus(ctx context.Context, fw *v1alpha1.FileWatch, newStatus *v1alpha1.FileWatchStatus) error { 140 if apicmp.DeepEqual(newStatus, &fw.Status) { 141 return nil 142 } 143 144 oldError := fw.Status.Error 145 146 update := fw.DeepCopy() 147 update.Status = *newStatus 148 err := c.Client.Status().Update(ctx, update) 149 if err != nil { 150 return err 151 } 152 153 if update.Status.Error != "" && oldError != update.Status.Error { 154 logger.Get(ctx).Errorf("filewatch %s: %s", fw.Name, update.Status.Error) 155 } 156 157 c.Store.Dispatch(NewFileWatchUpdateStatusAction(update)) 158 return nil 159 } 160 161 func (c *Controller) CreateBuilder(mgr ctrl.Manager) (*builder.Builder, error) { 162 b := ctrl.NewControllerManagedBy(mgr). 163 For(&v1alpha1.FileWatch{}). 164 Watches(&v1alpha1.ConfigMap{}, 165 handler.EnqueueRequestsFromMapFunc((c.indexer.Enqueue))). 166 WatchesRawSource(c.requeuer) 167 168 return b, nil 169 } 170 171 // removeWatch removes a watch from the map. It does NOT stop the watcher or free up resources. 172 // 173 // mu must be held before calling. 174 func (c *Controller) removeWatch(tw *watcher) { 175 if entry, ok := c.targetWatches[tw.name]; ok && tw == entry { 176 delete(c.targetWatches, tw.name) 177 } 178 } 179 180 func (c *Controller) addOrReplace(ctx context.Context, name types.NamespacedName, fw *v1alpha1.FileWatch) { 181 existing, hasExisting := c.targetWatches[name] 182 status := &v1alpha1.FileWatchStatus{} 183 w := &watcher{ 184 name: name, 185 spec: *fw.Spec.DeepCopy(), 186 clock: c.clock, 187 restartBackoff: time.Second, 188 } 189 if hasExisting && apicmp.DeepEqual(existing.spec, w.spec) { 190 w.restartBackoff = existing.restartBackoff 191 status.Error = existing.status.Error 192 } 193 194 ignoreMatcher := ignore.CreateFileChangeFilter(fw.Spec.Ignores) 195 startFileChangeLoop := false 196 notify, err := c.fsWatcherMaker( 197 append([]string{}, fw.Spec.WatchedPaths...), 198 ignoreMatcher, 199 logger.Get(ctx)) 200 if err != nil { 201 status.Error = fmt.Sprintf("filewatch init: %v", err) 202 } else if err := notify.Start(); err != nil { 203 status.Error = fmt.Sprintf("filewatch init: %v", err) 204 205 // Close the notify immediately, but don't add it to the watcher object. The 206 // watcher object is still needed to handle backoff. 207 _ = notify.Close() 208 } else { 209 startFileChangeLoop = true 210 } 211 212 if hasExisting { 213 // Clean up the existing watch AFTER the new watch has been started. 214 existing.cleanupWatch(ctx) 215 } 216 217 ctx, cancel := context.WithCancel(ctx) 218 w.cancel = cancel 219 220 if startFileChangeLoop { 221 w.notify = notify 222 status.MonitorStartTime = apis.NowMicro() 223 go c.dispatchFileChangesLoop(ctx, w) 224 } 225 226 w.status = status 227 c.targetWatches[name] = w 228 } 229 230 func (c *Controller) dispatchFileChangesLoop(ctx context.Context, w *watcher) { 231 eventsCh := fsevent.Coalesce(c.timerMaker, w.notify.Events()) 232 233 defer func() { 234 c.mu.Lock() 235 defer c.mu.Unlock() 236 w.cleanupWatch(ctx) 237 c.requeuer.Add(w.name) 238 }() 239 240 for { 241 select { 242 case err, ok := <-w.notify.Errors(): 243 if !ok { 244 return 245 } 246 247 if watch.IsWindowsShortReadError(err) { 248 w.recordError(fmt.Errorf("Windows I/O overflow.\n"+ 249 "You may be able to fix this by setting the env var %s.\n"+ 250 "Current buffer size: %d\n"+ 251 "More details: https://github.com/tilt-dev/tilt/issues/3556\n"+ 252 "Caused by: %v", 253 watch.WindowsBufferSizeEnvVar, 254 watch.DesiredWindowsBufferSize(), 255 err)) 256 } else if err.Error() == fsnotify.ErrEventOverflow.Error() { 257 w.recordError(fmt.Errorf("%s\nerror: %v", DetectedOverflowErrMsg, err)) 258 } else { 259 w.recordError(err) 260 } 261 c.requeuer.Add(w.name) 262 263 case <-ctx.Done(): 264 return 265 case fsEvents, ok := <-eventsCh: 266 if !ok { 267 return 268 } 269 w.recordEvent(fsEvents) 270 c.requeuer.Add(w.name) 271 } 272 } 273 } 274 275 // Find all the objects to watch based on the Filewatch model 276 func indexFw(obj ctrlclient.Object) []indexer.Key { 277 fw := obj.(*v1alpha1.FileWatch) 278 result := []indexer.Key{} 279 280 if fw.Spec.DisableSource != nil { 281 cm := fw.Spec.DisableSource.ConfigMap 282 if cm != nil { 283 gvk := v1alpha1.SchemeGroupVersion.WithKind("ConfigMap") 284 result = append(result, indexer.Key{ 285 Name: types.NamespacedName{Name: cm.Name}, 286 GVK: gvk, 287 }) 288 } 289 } 290 291 return result 292 }