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  }