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

     1  package filewatch
     2  
     3  import (
     4  	"fmt"
     5  	"io"
     6  	"path/filepath"
     7  	"runtime"
     8  	"strconv"
     9  	"strings"
    10  	"testing"
    11  	"time"
    12  
    13  	"github.com/jonboulle/clockwork"
    14  	"github.com/stretchr/testify/assert"
    15  	"github.com/stretchr/testify/require"
    16  	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
    17  	"k8s.io/apimachinery/pkg/types"
    18  	ctrl "sigs.k8s.io/controller-runtime"
    19  
    20  	"github.com/tilt-dev/tilt/internal/controllers/core/filewatch/fsevent"
    21  	"github.com/tilt-dev/tilt/internal/controllers/fake"
    22  	"github.com/tilt-dev/tilt/internal/store"
    23  	"github.com/tilt-dev/tilt/internal/testutils/configmap"
    24  	"github.com/tilt-dev/tilt/internal/testutils/tempdir"
    25  	"github.com/tilt-dev/tilt/internal/watch"
    26  	"github.com/tilt-dev/tilt/pkg/apis"
    27  	filewatches "github.com/tilt-dev/tilt/pkg/apis/core/v1alpha1"
    28  	"github.com/tilt-dev/tilt/pkg/logger"
    29  )
    30  
    31  // Test constants
    32  const timeout = time.Second
    33  const interval = 5 * time.Millisecond
    34  
    35  type testStore struct {
    36  	*store.TestingStore
    37  	out io.Writer
    38  }
    39  
    40  func NewTestingStore(out io.Writer) *testStore {
    41  	return &testStore{
    42  		TestingStore: store.NewTestingStore(),
    43  		out:          out,
    44  	}
    45  }
    46  
    47  func (s *testStore) Dispatch(action store.Action) {
    48  	s.TestingStore.Dispatch(action)
    49  	if action, ok := action.(store.LogAction); ok {
    50  		_, _ = s.out.Write(action.Message())
    51  	}
    52  }
    53  
    54  type fixture struct {
    55  	*fake.ControllerFixture
    56  	t                testing.TB
    57  	tmpdir           *tempdir.TempDirFixture
    58  	controller       *Controller
    59  	store            *testStore
    60  	fakeMultiWatcher *fsevent.FakeMultiWatcher
    61  	fakeTimerMaker   fsevent.FakeTimerMaker
    62  	clock            clockwork.FakeClock
    63  }
    64  
    65  func newFixture(t *testing.T) *fixture {
    66  	tmpdir := tempdir.NewTempDirFixture(t)
    67  	tmpdir.Chdir()
    68  
    69  	timerMaker := fsevent.MakeFakeTimerMaker(t)
    70  	fakeMultiWatcher := fsevent.NewFakeMultiWatcher()
    71  
    72  	cfb := fake.NewControllerFixtureBuilder(t)
    73  	testingStore := NewTestingStore(cfb.OutWriter())
    74  	clock := clockwork.NewFakeClock()
    75  	controller := NewController(cfb.Client, testingStore, fakeMultiWatcher.NewSub, timerMaker.Maker(), filewatches.NewScheme(), clock)
    76  
    77  	return &fixture{
    78  		ControllerFixture: cfb.WithRequeuer(controller.requeuer).Build(controller),
    79  		t:                 t,
    80  		tmpdir:            tmpdir,
    81  		controller:        controller,
    82  		store:             testingStore,
    83  		fakeMultiWatcher:  fakeMultiWatcher,
    84  		fakeTimerMaker:    timerMaker,
    85  		clock:             clock,
    86  	}
    87  }
    88  
    89  func (f *fixture) ChangeAndWaitForSeenFile(key types.NamespacedName, pathElems ...string) {
    90  	f.t.Helper()
    91  	f.ChangeFile(pathElems...)
    92  	f.WaitForSeenFile(key, pathElems...)
    93  }
    94  
    95  func (f *fixture) ChangeFile(elem ...string) {
    96  	f.t.Helper()
    97  	path, err := filepath.Abs(f.tmpdir.JoinPath(elem...))
    98  	require.NoErrorf(f.t, err, "Could not get abs path for %q", path)
    99  	select {
   100  	case f.fakeMultiWatcher.Events <- watch.NewFileEvent(path):
   101  	default:
   102  		f.t.Fatal("emitting a FileEvent would block. Perhaps there are too many events or the buffer size is too small.")
   103  	}
   104  }
   105  
   106  func (f *fixture) WaitForSeenFile(key types.NamespacedName, pathElems ...string) {
   107  	f.t.Helper()
   108  	relPath := filepath.Join(pathElems...)
   109  	var seenPaths []string
   110  	require.Eventuallyf(f.t, func() bool {
   111  		seenPaths = nil
   112  		var fw filewatches.FileWatch
   113  		if !f.Get(key, &fw) {
   114  			return false
   115  		}
   116  		found := false
   117  		for _, e := range fw.Status.FileEvents {
   118  			for _, p := range e.SeenFiles {
   119  				// relativize all the paths before comparison/storage
   120  				// (this makes the test output way more comprehensible on failure by hiding all the tmpdir cruft)
   121  				p, _ = filepath.Rel(f.tmpdir.Path(), p)
   122  				if p == relPath {
   123  					found = true
   124  				}
   125  				seenPaths = append(seenPaths, p)
   126  			}
   127  		}
   128  		return found
   129  	}, 2*time.Second, 20*time.Millisecond, "Did not find path %q, seen: %v", relPath, &seenPaths)
   130  }
   131  
   132  func (f *fixture) CreateSimpleFileWatch() (types.NamespacedName, *filewatches.FileWatch) {
   133  	f.t.Helper()
   134  	fw := &filewatches.FileWatch{
   135  		ObjectMeta: metav1.ObjectMeta{
   136  			Namespace: apis.SanitizeName(f.t.Name()),
   137  			Name:      "test-file-watch",
   138  		},
   139  		Spec: filewatches.FileWatchSpec{
   140  			WatchedPaths: []string{f.tmpdir.JoinPath("a"), f.tmpdir.JoinPath("b", "c")},
   141  			DisableSource: &filewatches.DisableSource{
   142  				ConfigMap: &filewatches.ConfigMapDisableSource{
   143  					Name: "disable-test-file-watch",
   144  					Key:  "isDisabled",
   145  				},
   146  			},
   147  		},
   148  	}
   149  	f.Create(fw)
   150  
   151  	f.setDisabled(types.NamespacedName{Namespace: fw.Namespace, Name: fw.Name}, false)
   152  	return f.KeyForObject(fw), fw
   153  }
   154  
   155  func (f *fixture) reconcileFw(key types.NamespacedName) {
   156  	_, err := f.controller.Reconcile(f.Context(), ctrl.Request{NamespacedName: key})
   157  	require.NoError(f.T(), err)
   158  }
   159  
   160  func (f *fixture) setDisabled(key types.NamespacedName, isDisabled bool) {
   161  	fw := &filewatches.FileWatch{}
   162  	err := f.Client.Get(f.Context(), key, fw)
   163  	require.NoError(f.T(), err)
   164  
   165  	// Make sure that there's a `DisableSource` set on fw
   166  	require.NotNil(f.T(), fw.Spec.DisableSource)
   167  	require.NotNil(f.T(), fw.Spec.DisableSource.ConfigMap)
   168  
   169  	ds := fw.Spec.DisableSource.ConfigMap
   170  	err = configmap.UpsertDisableConfigMap(f.Context(), f.Client, ds.Name, ds.Key, isDisabled)
   171  	require.NoError(f.T(), err)
   172  
   173  	f.reconcileFw(key)
   174  
   175  	require.Eventually(f.T(), func() bool {
   176  		err := f.Client.Get(f.Context(), key, fw)
   177  		require.NoError(f.T(), err)
   178  
   179  		return fw.Status.DisableStatus != nil && fw.Status.DisableStatus.Disabled == isDisabled
   180  	}, timeout, interval)
   181  }
   182  
   183  func TestController_LimitFileEventsHistory(t *testing.T) {
   184  	f := newFixture(t)
   185  
   186  	key, fw := f.CreateSimpleFileWatch()
   187  
   188  	const eventOverflowCount = 5
   189  	for i := 0; i < MaxFileEventHistory+eventOverflowCount; i++ {
   190  		// need to wait for each file 1-by-1 to prevent batching
   191  		f.ChangeAndWaitForSeenFile(key, "a", strconv.Itoa(i))
   192  	}
   193  
   194  	f.MustGet(key, fw)
   195  	require.Equal(t, MaxFileEventHistory, len(fw.Status.FileEvents), "Wrong number of file events")
   196  	for i := 0; i < len(fw.Status.FileEvents); i++ {
   197  		p := f.tmpdir.JoinPath("a", strconv.Itoa(i+eventOverflowCount))
   198  		assert.Contains(t, fw.Status.FileEvents[i].SeenFiles, p)
   199  	}
   200  }
   201  
   202  func TestController_ShortRead(t *testing.T) {
   203  	f := newFixture(t)
   204  	key, _ := f.CreateSimpleFileWatch()
   205  
   206  	f.fakeMultiWatcher.Errors <- fmt.Errorf("short read on readEvents()")
   207  
   208  	require.Eventuallyf(t, func() bool {
   209  		return strings.Contains(f.Stdout(), "short read")
   210  	}, time.Second, 10*time.Millisecond, "short read error was not propagated")
   211  
   212  	if runtime.GOOS == "windows" {
   213  		assert.Contains(t, f.Stdout(), "https://github.com/tilt-dev/tilt/issues/3556")
   214  	}
   215  
   216  	var fw filewatches.FileWatch
   217  	f.MustGet(key, &fw)
   218  	assert.Contains(t, fw.Status.Error, "short read on readEvents()")
   219  }
   220  
   221  func TestController_IgnoreEphemeralFiles(t *testing.T) {
   222  	f := newFixture(t)
   223  	key, orig := f.CreateSimpleFileWatch()
   224  	// spec should have no ignores - these are purely implicit ignores
   225  	require.Empty(t, orig.Spec.Ignores)
   226  
   227  	// sandwich in some ignored files with seen files on the outside as synchronization
   228  	f.ChangeAndWaitForSeenFile(key, "a", "start")
   229  	// see internal/ignore/ephemeral.go for where these come from - they're NOT part of a FileWatch spec
   230  	// but are always included at the filesystem watcher level by Tilt
   231  	f.ChangeFile("a", ".idea", "workspace.xml")
   232  	f.ChangeFile("b", "c", ".vim.swp")
   233  	f.ChangeAndWaitForSeenFile(key, "b", "c", "stop")
   234  
   235  	var fw filewatches.FileWatch
   236  	f.MustGet(key, &fw)
   237  	require.Equal(t, 2, len(fw.Status.FileEvents), "Wrong file event count")
   238  	assert.Equal(t, []string{f.tmpdir.JoinPath("a", "start")}, fw.Status.FileEvents[0].SeenFiles)
   239  	assert.Equal(t, []string{f.tmpdir.JoinPath("b", "c", "stop")}, fw.Status.FileEvents[1].SeenFiles)
   240  }
   241  
   242  // TestController_Watcher_Cancel peeks into internal/unexported portions of the controller to inspect the actual
   243  // filesystem monitor so it can ensure reconciler is not leaking resources; other tests should prefer observing
   244  // desired state!
   245  func TestController_Watcher_Cancel(t *testing.T) {
   246  	f := newFixture(t)
   247  	key, _ := f.CreateSimpleFileWatch()
   248  
   249  	assert.Equalf(t, 1, len(f.controller.targetWatches), "There should be exactly one file watcher")
   250  	watcher := f.controller.targetWatches[key]
   251  	require.NotNilf(t, watcher, "Watcher does not exist for %q", key.String())
   252  
   253  	// cancel the root context, which should propagate to the watcher
   254  	f.Cancel()
   255  
   256  	require.Eventuallyf(t, func() bool {
   257  		watcher.mu.Lock()
   258  		defer watcher.mu.Unlock()
   259  		return watcher.done
   260  	}, time.Second, 10*time.Millisecond, "Watcher was never cleaned up")
   261  }
   262  
   263  func TestController_Reconcile_Create(t *testing.T) {
   264  	f := newFixture(t)
   265  	key, fw := f.CreateSimpleFileWatch()
   266  
   267  	f.MustGet(key, fw)
   268  	assert.NotZero(t, fw.Status.MonitorStartTime, "Filesystem monitor was not started")
   269  }
   270  
   271  // TestController_Reconcile_Delete peeks into internal/unexported portions of the controller to inspect the actual
   272  // filesystem monitor so it can ensure reconciler is not leaking resources; other tests should prefer observing
   273  // desired state!
   274  func TestController_Reconcile_Delete(t *testing.T) {
   275  	f := newFixture(t)
   276  	key, fw := f.CreateSimpleFileWatch()
   277  
   278  	assert.Equalf(t, 1, len(f.controller.targetWatches), "There should be exactly one file watcher")
   279  	watcher := f.controller.targetWatches[key]
   280  	require.NotNilf(t, watcher, "Watcher does not exist for %q", key.String())
   281  
   282  	deleted, _ := f.Delete(fw)
   283  	require.True(t, deleted, "FileWatch was not deleted")
   284  
   285  	watcher.mu.Lock()
   286  	defer watcher.mu.Unlock()
   287  	require.True(t, watcher.done, "Watcher was not stopped")
   288  	require.Empty(t, f.controller.targetWatches, "There should not be any remaining file watchers")
   289  }
   290  
   291  func TestController_Reconcile_Watches(t *testing.T) {
   292  	f := newFixture(t)
   293  	key, fw := f.CreateSimpleFileWatch()
   294  
   295  	f.ChangeAndWaitForSeenFile(key, "a", "1")
   296  
   297  	f.MustGet(key, fw)
   298  	originalStart := fw.Status.MonitorStartTime.Time
   299  	assert.NotZero(t, originalStart, "Filesystem monitor was not started")
   300  
   301  	fw.Spec.Ignores = []filewatches.IgnoreDef{
   302  		{
   303  			BasePath: f.tmpdir.Path(),
   304  			Patterns: []string{"**/ignore_me"},
   305  		},
   306  		{
   307  			// no patterns means ignore the path recursively
   308  			BasePath: f.tmpdir.JoinPath("d", "ignore_dir"),
   309  		},
   310  	}
   311  	fw.Spec.WatchedPaths = []string{f.tmpdir.JoinPath("d")}
   312  	f.Update(fw)
   313  
   314  	// sandwich in some ignored files with seen files on the outside as synchronization
   315  	f.ChangeAndWaitForSeenFile(key, "d", "1")
   316  	f.ChangeFile("a", "2")
   317  	f.ChangeFile("d", "ignore_me")
   318  	f.ChangeFile("d", "ignore_dir", "file")
   319  	f.ChangeAndWaitForSeenFile(key, "d", "2")
   320  
   321  	var updated filewatches.FileWatch
   322  	f.MustGet(key, &updated)
   323  	updatedStart := updated.Status.MonitorStartTime.Time
   324  	assert.Truef(t, updatedStart.After(originalStart),
   325  		"Monitor start time should be more recent after update, (original: %s, updated: %s)",
   326  		originalStart, updatedStart)
   327  	if assert.Equal(t, 2, len(updated.Status.FileEvents)) {
   328  		// ensure ONLY the expected files were seen
   329  		assert.NotZero(t, updated.Status.FileEvents[0].Time.Time)
   330  		mostRecentEventTime := updated.Status.FileEvents[1].Time.Time
   331  		assert.NotZero(t, mostRecentEventTime)
   332  		assert.Equal(t, []string{f.tmpdir.JoinPath("d", "1")}, updated.Status.FileEvents[0].SeenFiles)
   333  		assert.Equal(t, []string{f.tmpdir.JoinPath("d", "2")}, updated.Status.FileEvents[1].SeenFiles)
   334  		assert.Equal(t, mostRecentEventTime, updated.Status.LastEventTime.Time)
   335  	}
   336  }
   337  
   338  func TestController_Disable_By_Configmap(t *testing.T) {
   339  	f := newFixture(t)
   340  	key, _ := f.CreateSimpleFileWatch()
   341  
   342  	// when enabling the configmap, the filewatch object is enabled
   343  	f.setDisabled(key, false)
   344  
   345  	// when disabling the configmap, the filewatch object is disabled
   346  	f.setDisabled(key, true)
   347  
   348  	// when enabling the configmap, the filewatch object is enabled
   349  	f.setDisabled(key, false)
   350  }
   351  
   352  func TestController_Disable_Ignores_File_Changes(t *testing.T) {
   353  	f := newFixture(t)
   354  	key, _ := f.CreateSimpleFileWatch()
   355  
   356  	// Disable the filewatch
   357  	f.setDisabled(key, true)
   358  	// Change a watched file
   359  	f.ChangeFile("a", "1")
   360  
   361  	// Expect that no file events were triggered
   362  	var fwAfterDisable filewatches.FileWatch
   363  	f.MustGet(key, &fwAfterDisable)
   364  	require.Equal(t, 0, len(fwAfterDisable.Status.FileEvents))
   365  }
   366  
   367  func TestCreateSubError(t *testing.T) {
   368  	f := newFixture(t)
   369  	f.controller.fsWatcherMaker = fsevent.WatcherMaker(func(paths []string, ignore watch.PathMatcher, _ logger.Logger) (watch.Notify, error) {
   370  		var nilWatcher *fsevent.FakeWatcher = nil
   371  		return nilWatcher, fmt.Errorf("Unusual watcher error")
   372  	})
   373  	key, _ := f.CreateSimpleFileWatch()
   374  
   375  	// Expect that no file events were triggered
   376  	var fw filewatches.FileWatch
   377  	f.MustGet(key, &fw)
   378  	assert.Contains(t, fw.Status.Error, "filewatch init: Unusual watcher error")
   379  }
   380  
   381  func TestStartSubError(t *testing.T) {
   382  	f := newFixture(t)
   383  	maker := f.controller.fsWatcherMaker
   384  	var ffw *fsevent.FakeWatcher
   385  	f.controller.fsWatcherMaker = fsevent.WatcherMaker(func(paths []string, ignore watch.PathMatcher, l logger.Logger) (watch.Notify, error) {
   386  		w, err := maker(paths, ignore, l)
   387  		ffw = w.(*fsevent.FakeWatcher)
   388  		ffw.StartErr = fmt.Errorf("Unusual start error")
   389  		return w, err
   390  	})
   391  	key, _ := f.CreateSimpleFileWatch()
   392  
   393  	var fw filewatches.FileWatch
   394  	f.MustGet(key, &fw)
   395  	assert.Contains(t, fw.Status.Error, "filewatch init: Unusual start error")
   396  	assert.False(t, ffw.Running)
   397  
   398  	fw.Spec.WatchedPaths = []string{f.tmpdir.JoinPath("d")}
   399  	f.Update(&fw)
   400  
   401  	f.MustGet(key, &fw)
   402  	assert.Contains(t, fw.Status.Error, "filewatch init: Unusual start error")
   403  	assert.False(t, ffw.Running)
   404  }