github.com/tilt-dev/tilt@v0.36.0/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 if hasExisting { 194 status.FileEvents = existing.status.FileEvents 195 status.LastEventTime = existing.status.LastEventTime 196 } 197 198 ignoreMatcher := ignore.CreateFileChangeFilter(fw.Spec.Ignores) 199 startFileChangeLoop := false 200 notify, err := c.fsWatcherMaker( 201 append([]string{}, fw.Spec.WatchedPaths...), 202 ignoreMatcher, 203 logger.Get(ctx)) 204 if err != nil { 205 status.Error = fmt.Sprintf("filewatch init: %v", err) 206 } else if err := notify.Start(); err != nil { 207 status.Error = fmt.Sprintf("filewatch init: %v", err) 208 209 // Close the notify immediately, but don't add it to the watcher object. The 210 // watcher object is still needed to handle backoff. 211 _ = notify.Close() 212 } else { 213 startFileChangeLoop = true 214 } 215 216 if hasExisting { 217 // Clean up the existing watch AFTER the new watch has been started. 218 existing.cleanupWatch(ctx) 219 } 220 221 ctx, cancel := context.WithCancel(ctx) 222 w.cancel = cancel 223 224 if startFileChangeLoop { 225 w.notify = notify 226 status.MonitorStartTime = apis.NowMicro() 227 go c.dispatchFileChangesLoop(ctx, w) 228 } 229 230 w.status = status 231 c.targetWatches[name] = w 232 } 233 234 func (c *Controller) dispatchFileChangesLoop(ctx context.Context, w *watcher) { 235 eventsCh := fsevent.Coalesce(c.timerMaker, w.notify.Events()) 236 237 defer func() { 238 c.mu.Lock() 239 defer c.mu.Unlock() 240 w.cleanupWatch(ctx) 241 c.requeuer.Add(w.name) 242 }() 243 244 for { 245 select { 246 case err, ok := <-w.notify.Errors(): 247 if !ok { 248 return 249 } 250 251 if watch.IsWindowsShortReadError(err) { 252 w.recordError(fmt.Errorf("Windows I/O overflow.\n"+ 253 "You may be able to fix this by setting the env var %s.\n"+ 254 "Current buffer size: %d\n"+ 255 "More details: https://github.com/tilt-dev/tilt/issues/3556\n"+ 256 "Caused by: %v", 257 watch.WindowsBufferSizeEnvVar, 258 watch.DesiredWindowsBufferSize(), 259 err)) 260 } else if err.Error() == fsnotify.ErrEventOverflow.Error() { 261 w.recordError(fmt.Errorf("%s\nerror: %v", DetectedOverflowErrMsg, err)) 262 } else { 263 w.recordError(err) 264 } 265 c.requeuer.Add(w.name) 266 267 case <-ctx.Done(): 268 return 269 case fsEvents, ok := <-eventsCh: 270 if !ok { 271 return 272 } 273 w.recordEvent(fsEvents) 274 c.requeuer.Add(w.name) 275 } 276 } 277 } 278 279 // Find all the objects to watch based on the Filewatch model 280 func indexFw(obj ctrlclient.Object) []indexer.Key { 281 fw := obj.(*v1alpha1.FileWatch) 282 result := []indexer.Key{} 283 284 if fw.Spec.DisableSource != nil { 285 cm := fw.Spec.DisableSource.ConfigMap 286 if cm != nil { 287 gvk := v1alpha1.SchemeGroupVersion.WithKind("ConfigMap") 288 result = append(result, indexer.Key{ 289 Name: types.NamespacedName{Name: cm.Name}, 290 GVK: gvk, 291 }) 292 } 293 } 294 295 return result 296 }