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 }