github.com/tilt-dev/tilt@v0.33.15-0.20240515162809-0a22ed45d8a0/internal/controllers/core/tiltfile/reconciler_test.go (about) 1 package tiltfile 2 3 import ( 4 "bytes" 5 "context" 6 "fmt" 7 "path/filepath" 8 "reflect" 9 "strconv" 10 "testing" 11 "time" 12 13 "github.com/pkg/errors" 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 "k8s.io/client-go/util/workqueue" 19 "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" 20 "sigs.k8s.io/controller-runtime/pkg/reconcile" 21 22 "github.com/tilt-dev/tilt/internal/container" 23 configmap2 "github.com/tilt-dev/tilt/internal/controllers/apis/configmap" 24 "github.com/tilt-dev/tilt/internal/controllers/apis/uibutton" 25 "github.com/tilt-dev/tilt/internal/controllers/fake" 26 "github.com/tilt-dev/tilt/internal/docker" 27 "github.com/tilt-dev/tilt/internal/k8s/testyaml" 28 "github.com/tilt-dev/tilt/internal/store" 29 "github.com/tilt-dev/tilt/internal/testutils/configmap" 30 "github.com/tilt-dev/tilt/internal/testutils/manifestbuilder" 31 "github.com/tilt-dev/tilt/internal/testutils/tempdir" 32 "github.com/tilt-dev/tilt/internal/tiltfile" 33 "github.com/tilt-dev/tilt/pkg/apis/core/v1alpha1" 34 "github.com/tilt-dev/tilt/pkg/model" 35 "github.com/tilt-dev/wmclient/pkg/analytics" 36 ) 37 38 func TestDefault(t *testing.T) { 39 f := newFixture(t) 40 p := f.tempdir.JoinPath("Tiltfile") 41 f.tempdir.WriteFile(p, "print('hello-world')") 42 43 tf := v1alpha1.Tiltfile{ 44 ObjectMeta: metav1.ObjectMeta{ 45 Name: "my-tf", 46 }, 47 Spec: v1alpha1.TiltfileSpec{ 48 Path: p, 49 }, 50 } 51 ts := time.Now() 52 f.Create(&tf) 53 54 // Make sure the FileWatch was created 55 var fw v1alpha1.FileWatch 56 fwKey := types.NamespacedName{Name: "configs:my-tf"} 57 f.MustGet(fwKey, &fw) 58 assert.Equal(t, tf.Spec.Path, fw.Spec.WatchedPaths[0]) 59 60 f.waitForRunning(tf.Name) 61 62 f.popQueue() 63 64 f.waitForTerminatedAfter(tf.Name, ts) 65 66 f.Delete(&tf) 67 68 // Ensure the FileWatch was deleted. 69 assert.False(t, f.Get(fwKey, &fw)) 70 } 71 72 func TestSteadyState(t *testing.T) { 73 f := newFixture(t) 74 p := f.tempdir.JoinPath("Tiltfile") 75 f.tempdir.WriteFile(p, "print('hello-world')") 76 77 tf := v1alpha1.Tiltfile{ 78 ObjectMeta: metav1.ObjectMeta{ 79 Name: "my-tf", 80 }, 81 Spec: v1alpha1.TiltfileSpec{ 82 Path: p, 83 }, 84 } 85 f.createAndWaitForLoaded(&tf) 86 87 // Make sure a second reconcile doesn't update the status again. 88 var tf2 = v1alpha1.Tiltfile{} 89 f.MustReconcile(types.NamespacedName{Name: "my-tf"}) 90 f.MustGet(types.NamespacedName{Name: "my-tf"}, &tf2) 91 assert.Equal(t, tf.ResourceVersion, tf2.ResourceVersion) 92 } 93 94 func TestLiveUpdate(t *testing.T) { 95 f := newFixture(t) 96 p := f.tempdir.JoinPath("Tiltfile") 97 98 luSpec := v1alpha1.LiveUpdateSpec{ 99 BasePath: f.tempdir.Path(), 100 StopPaths: []string{filepath.Join("src", "package.json")}, 101 Syncs: []v1alpha1.LiveUpdateSync{{LocalPath: "src", ContainerPath: "/src"}}, 102 } 103 expectedSpec := *(luSpec.DeepCopy()) 104 expectedSpec.Sources = []v1alpha1.LiveUpdateSource{{ 105 FileWatch: "image:sancho-image", 106 ImageMap: "sancho-image", 107 }} 108 expectedSpec.Selector.Kubernetes = &v1alpha1.LiveUpdateKubernetesSelector{ 109 ImageMapName: "sancho-image", 110 DiscoveryName: "sancho", 111 ApplyName: "sancho", 112 } 113 114 sanchoImage := model.MustNewImageTarget(container.MustParseSelector("sancho-image")). 115 WithLiveUpdateSpec("sancho:sancho-image", luSpec). 116 WithDockerImage(v1alpha1.DockerImageSpec{Context: f.tempdir.Path()}) 117 sancho := manifestbuilder.New(f.tempdir, "sancho"). 118 WithImageTargets(sanchoImage). 119 WithK8sYAML(testyaml.SanchoYAML). 120 Build() 121 f.tfl.Result = tiltfile.TiltfileLoadResult{ 122 Manifests: []model.Manifest{sancho}, 123 } 124 125 tf := v1alpha1.Tiltfile{ 126 ObjectMeta: metav1.ObjectMeta{ 127 Name: "my-tf", 128 }, 129 Spec: v1alpha1.TiltfileSpec{ 130 Path: p, 131 }, 132 } 133 f.createAndWaitForLoaded(&tf) 134 135 assert.Equal(t, "", tf.Status.Terminated.Error) 136 137 var luList = v1alpha1.LiveUpdateList{} 138 f.List(&luList) 139 if assert.Equal(t, 1, len(luList.Items)) { 140 assert.Equal(t, "sancho:sancho-image", luList.Items[0].Name) 141 assert.Equal(t, expectedSpec, luList.Items[0].Spec) 142 } 143 } 144 145 func TestCluster(t *testing.T) { 146 f := newFixture(t) 147 p := f.tempdir.JoinPath("Tiltfile") 148 f.r.k8sContextOverride = "context-override" 149 f.r.k8sNamespaceOverride = "namespace-override" 150 151 expected := &v1alpha1.ClusterConnection{ 152 Kubernetes: &v1alpha1.KubernetesClusterConnection{ 153 Context: string(f.r.k8sContextOverride), 154 Namespace: string(f.r.k8sNamespaceOverride), 155 }, 156 } 157 158 sancho := manifestbuilder.New(f.tempdir, "sancho"). 159 WithK8sYAML(testyaml.SanchoYAML). 160 Build() 161 f.tfl.Result = tiltfile.TiltfileLoadResult{ 162 Manifests: []model.Manifest{sancho}, 163 } 164 165 name := model.MainTiltfileManifestName.String() 166 tf := v1alpha1.Tiltfile{ 167 ObjectMeta: metav1.ObjectMeta{ 168 Name: name, 169 }, 170 Spec: v1alpha1.TiltfileSpec{ 171 Path: p, 172 }, 173 } 174 f.createAndWaitForLoaded(&tf) 175 176 assert.Equal(t, "", tf.Status.Terminated.Error) 177 178 var clList = v1alpha1.ClusterList{} 179 f.List(&clList) 180 if assert.Equal(t, 1, len(clList.Items)) { 181 assert.Equal(t, "default", clList.Items[0].Name) 182 assert.Equal(t, expected, clList.Items[0].Spec.Connection) 183 } 184 } 185 186 func TestLocalServe(t *testing.T) { 187 f := newFixture(t) 188 p := f.tempdir.JoinPath("Tiltfile") 189 190 m := manifestbuilder.New(f.tempdir, "foo").WithLocalServeCmd(".").Build() 191 f.tfl.Result = tiltfile.TiltfileLoadResult{ 192 Manifests: []model.Manifest{m}, 193 } 194 195 tf := v1alpha1.Tiltfile{ 196 ObjectMeta: metav1.ObjectMeta{ 197 Name: "my-tf", 198 }, 199 Spec: v1alpha1.TiltfileSpec{ 200 Path: p, 201 }, 202 } 203 f.createAndWaitForLoaded(&tf) 204 205 assert.Equal(t, "", tf.Status.Terminated.Error) 206 207 a := f.st.WaitForAction(t, reflect.TypeOf(ConfigsReloadedAction{})).(ConfigsReloadedAction) 208 require.Equal(t, 1, len(a.Manifests)) 209 m = a.Manifests[0] 210 require.Equal(t, model.ManifestName("foo"), m.Name) 211 require.IsType(t, model.LocalTarget{}, m.DeployTarget) 212 lt := m.DeployTarget.(model.LocalTarget) 213 require.NotNil(t, lt.ServeCmdDisableSource, "ServeCmdDisableSource is nil") 214 require.NotNil(t, lt.ServeCmdDisableSource.ConfigMap, "ServeCmdDisableSource.ConfigMap is nil") 215 require.Equal(t, "foo-disable", lt.ServeCmdDisableSource.ConfigMap.Name) 216 } 217 218 func TestDockerMetrics(t *testing.T) { 219 f := newFixture(t) 220 p := f.tempdir.JoinPath("Tiltfile") 221 222 sanchoImage := model.MustNewImageTarget(container.MustParseSelector("sancho-image")). 223 WithDockerImage(v1alpha1.DockerImageSpec{Context: f.tempdir.Path()}) 224 sancho := manifestbuilder.New(f.tempdir, "sancho"). 225 WithImageTargets(sanchoImage). 226 WithK8sYAML(testyaml.SanchoYAML). 227 Build() 228 229 f.tfl.Result = tiltfile.TiltfileLoadResult{ 230 Manifests: []model.Manifest{sancho}, 231 } 232 233 tf := v1alpha1.Tiltfile{ 234 ObjectMeta: metav1.ObjectMeta{ 235 Name: "my-tf", 236 }, 237 Spec: v1alpha1.TiltfileSpec{ 238 Path: p, 239 }, 240 } 241 f.createAndWaitForLoaded(&tf) 242 243 connectEvt := analytics.CountEvent{ 244 Name: "api.tiltfile.docker.connect", 245 Tags: map[string]string{ 246 "server.arch": "amd64", 247 "server.version": "20.10.11", 248 "status": "connected", 249 }, 250 N: 1, 251 } 252 assert.ElementsMatch(t, []analytics.CountEvent{connectEvt}, f.ma.Counts) 253 } 254 255 func TestArgsChangeResetsEnabledResources(t *testing.T) { 256 f := newFixture(t) 257 p := f.tempdir.JoinPath("Tiltfile") 258 259 m1 := manifestbuilder.New(f.tempdir, "m1").WithLocalServeCmd("hi").Build() 260 m2 := manifestbuilder.New(f.tempdir, "m2").WithLocalServeCmd("hi").Build() 261 f.tfl.Result = tiltfile.TiltfileLoadResult{ 262 Manifests: []model.Manifest{m1, m2}, 263 EnabledManifests: []model.ManifestName{"m1", "m2"}, 264 } 265 266 tf := v1alpha1.Tiltfile{ 267 ObjectMeta: metav1.ObjectMeta{ 268 Name: "my-tf", 269 }, 270 Spec: v1alpha1.TiltfileSpec{ 271 Path: p, 272 Args: []string{"m1", "m2"}, 273 }, 274 } 275 f.createAndWaitForLoaded(&tf) 276 277 ts := time.Now() 278 279 f.setArgs("my-tf", []string{"m2"}) 280 f.tfl.Result.EnabledManifests = []model.ManifestName{"m2"} 281 282 f.MustReconcile(types.NamespacedName{Name: "my-tf"}) 283 f.waitForRunning("my-tf") 284 f.popQueue() 285 f.waitForTerminatedAfter("my-tf", ts) 286 287 f.requireEnabled(m1, false) 288 f.requireEnabled(m2, true) 289 } 290 291 func TestRunWithoutArgsChangePreservesEnabledResources(t *testing.T) { 292 f := newFixture(t) 293 p := f.tempdir.JoinPath("Tiltfile") 294 295 m1 := manifestbuilder.New(f.tempdir, "m1").WithLocalServeCmd("hi").Build() 296 m2 := manifestbuilder.New(f.tempdir, "m2").WithLocalServeCmd("hi").Build() 297 f.tfl.Result = tiltfile.TiltfileLoadResult{ 298 Manifests: []model.Manifest{m1, m2}, 299 EnabledManifests: []model.ManifestName{"m1", "m2"}, 300 } 301 302 tf := v1alpha1.Tiltfile{ 303 ObjectMeta: metav1.ObjectMeta{ 304 Name: "my-tf", 305 }, 306 Spec: v1alpha1.TiltfileSpec{ 307 Path: p, 308 Args: []string{"m1"}, 309 }, 310 } 311 f.createAndWaitForLoaded(&tf) 312 313 err := configmap.UpsertDisableConfigMap(f.Context(), f.Client, "m2-disable", "isDisabled", false) 314 require.NoError(t, err) 315 316 f.setArgs("my-tf", tf.Spec.Args) 317 318 f.triggerRun("my-tf") 319 320 ts := time.Now() 321 f.MustReconcile(types.NamespacedName{Name: "my-tf"}) 322 f.waitForRunning("my-tf") 323 f.popQueue() 324 f.waitForTerminatedAfter("my-tf", ts) 325 326 f.requireEnabled(m1, true) 327 f.requireEnabled(m2, true) 328 } 329 330 func TestTiltfileFailurePreservesEnabledResources(t *testing.T) { 331 f := newFixture(t) 332 p := f.tempdir.JoinPath("Tiltfile") 333 334 m1 := manifestbuilder.New(f.tempdir, "m1").WithLocalServeCmd("hi").Build() 335 m2 := manifestbuilder.New(f.tempdir, "m2").WithLocalServeCmd("hi").Build() 336 f.tfl.Result = tiltfile.TiltfileLoadResult{ 337 Manifests: []model.Manifest{m1, m2}, 338 EnabledManifests: []model.ManifestName{"m1"}, 339 } 340 341 tf := v1alpha1.Tiltfile{ 342 ObjectMeta: metav1.ObjectMeta{ 343 Name: "my-tf", 344 }, 345 Spec: v1alpha1.TiltfileSpec{ 346 Path: p, 347 Args: []string{"m1"}, 348 }, 349 } 350 f.createAndWaitForLoaded(&tf) 351 352 f.tfl.Result = tiltfile.TiltfileLoadResult{ 353 Manifests: []model.Manifest{m1, m2}, 354 EnabledManifests: []model.ManifestName{}, 355 Error: errors.New("unknown manifest: m3"), 356 } 357 358 f.triggerRun("my-tf") 359 360 ts := time.Now() 361 f.MustReconcile(types.NamespacedName{Name: "my-tf"}) 362 f.waitForRunning("my-tf") 363 f.popQueue() 364 f.waitForTerminatedAfter("my-tf", ts) 365 366 f.requireEnabled(m1, true) 367 f.requireEnabled(m2, false) 368 } 369 370 func TestCancel(t *testing.T) { 371 f := newFixture(t) 372 p := f.tempdir.JoinPath("Tiltfile") 373 f.tempdir.WriteFile(p, "print('hello-world')") 374 375 f.tfl.Delegate = newBlockingTiltfileLoader() 376 377 tf := v1alpha1.Tiltfile{ 378 ObjectMeta: metav1.ObjectMeta{ 379 Name: "my-tf", 380 }, 381 Spec: v1alpha1.TiltfileSpec{ 382 Path: p, 383 StopOn: &v1alpha1.StopOnSpec{UIButtons: []string{uibutton.StopBuildButtonName("my-tf")}}, 384 }, 385 } 386 387 cancelButton := uibutton.StopBuildButton(tf.Name) 388 err := f.Client.Create(f.Context(), cancelButton) 389 require.NoError(t, err) 390 391 ts := time.Now() 392 f.Create(&tf) 393 394 f.waitForRunning(tf.Name) 395 396 cancelButton.Status.LastClickedAt = metav1.NowMicro() 397 f.UpdateStatus(cancelButton) 398 require.NoError(t, err) 399 400 f.MustReconcile(types.NamespacedName{Name: tf.Name}) 401 402 f.popQueue() 403 404 f.waitForTerminatedAfter(tf.Name, ts) 405 406 f.Get(types.NamespacedName{Name: tf.Name}, &tf) 407 require.NotNil(t, tf.Status.Terminated) 408 require.Equal(t, "build canceled", tf.Status.Terminated.Error) 409 } 410 411 func TestCancelClickedBeforeLoad(t *testing.T) { 412 f := newFixture(t) 413 p := f.tempdir.JoinPath("Tiltfile") 414 f.tempdir.WriteFile(p, "print('hello-world')") 415 416 tfl := newBlockingTiltfileLoader() 417 f.tfl.Delegate = tfl 418 419 tf := v1alpha1.Tiltfile{ 420 ObjectMeta: metav1.ObjectMeta{ 421 Name: "my-tf", 422 }, 423 Spec: v1alpha1.TiltfileSpec{ 424 Path: p, 425 StopOn: &v1alpha1.StopOnSpec{UIButtons: []string{uibutton.StopBuildButtonName("my-tf")}}, 426 }, 427 } 428 429 cancelButton := uibutton.StopBuildButton(tf.Name) 430 cancelButton.Status.LastClickedAt = metav1.NewMicroTime(time.Now().Add(-time.Second)) 431 err := f.Client.Create(f.Context(), cancelButton) 432 require.NoError(t, err) 433 434 nn := types.NamespacedName{Name: tf.Name} 435 436 ts := time.Now() 437 f.Create(&tf) 438 439 f.waitForRunning(tf.Name) 440 441 // give the reconciler a chance to observe the cancel button click 442 f.MustReconcile(nn) 443 444 // finish the build 445 tfl.Complete() 446 447 f.MustReconcile(nn) 448 449 f.popQueue() 450 451 f.waitForTerminatedAfter(tf.Name, ts) 452 453 f.Get(nn, &tf) 454 require.NotNil(t, tf.Status.Terminated) 455 require.Equal(t, "", tf.Status.Terminated.Error) 456 } 457 458 type testStore struct { 459 *store.TestingStore 460 out *bytes.Buffer 461 } 462 463 func NewTestingStore() *testStore { 464 return &testStore{ 465 TestingStore: store.NewTestingStore(), 466 out: bytes.NewBuffer(nil), 467 } 468 } 469 470 func (s *testStore) Dispatch(action store.Action) { 471 s.TestingStore.Dispatch(action) 472 473 logAction, ok := action.(store.LogAction) 474 if ok { 475 _, _ = fmt.Fprintf(s.out, "%s", logAction.Message()) 476 } 477 } 478 479 type fixture struct { 480 *fake.ControllerFixture 481 tempdir *tempdir.TempDirFixture 482 st *testStore 483 r *Reconciler 484 q workqueue.RateLimitingInterface 485 tfl *tiltfile.FakeTiltfileLoader 486 ma *analytics.MemoryAnalytics 487 } 488 489 func newFixture(t *testing.T) *fixture { 490 cfb := fake.NewControllerFixtureBuilder(t) 491 tf := tempdir.NewTempDirFixture(t) 492 493 st := NewTestingStore() 494 tfl := tiltfile.NewFakeTiltfileLoader() 495 d := docker.NewFakeClient() 496 r := NewReconciler(st, tfl, d, cfb.Client, v1alpha1.NewScheme(), store.EngineModeUp, "", "", 0) 497 q := workqueue.NewRateLimitingQueue( 498 workqueue.NewItemExponentialFailureRateLimiter(time.Millisecond, time.Millisecond)) 499 _ = r.requeuer.Start(context.Background(), q) 500 501 return &fixture{ 502 ControllerFixture: cfb.Build(r), 503 tempdir: tf, 504 st: st, 505 r: r, 506 q: q, 507 tfl: tfl, 508 ma: cfb.Analytics(), 509 } 510 } 511 512 // Wait for the next item on the workqueue, then run reconcile on it. 513 func (f *fixture) popQueue() { 514 f.T().Helper() 515 516 done := make(chan error) 517 go func() { 518 item, _ := f.q.Get() 519 _, err := f.r.Reconcile(f.Context(), item.(reconcile.Request)) 520 f.q.Done(item) 521 done <- err 522 }() 523 524 select { 525 case <-time.After(time.Second): 526 f.T().Fatal("timeout waiting for workqueue") 527 case err := <-done: 528 assert.NoError(f.T(), err) 529 } 530 } 531 532 func (f *fixture) waitForTerminatedAfter(name string, ts time.Time) { 533 require.Eventually(f.T(), func() bool { 534 var tf v1alpha1.Tiltfile 535 f.MustGet(types.NamespacedName{Name: name}, &tf) 536 return tf.Status.Terminated != nil && tf.Status.Terminated.FinishedAt.After(ts) 537 }, time.Second, time.Millisecond, "waiting for tiltfile to finish running") 538 } 539 540 func (f *fixture) waitForRunning(name string) { 541 require.Eventually(f.T(), func() bool { 542 var tf v1alpha1.Tiltfile 543 f.MustGet(types.NamespacedName{Name: name}, &tf) 544 return tf.Status.Running != nil 545 }, time.Second, time.Millisecond, "waiting for tiltfile to start running") 546 } 547 548 func (f *fixture) createAndWaitForLoaded(tf *v1alpha1.Tiltfile) { 549 ts := time.Now() 550 f.Create(tf) 551 552 f.waitForRunning(tf.Name) 553 554 f.popQueue() 555 556 f.waitForTerminatedAfter(tf.Name, ts) 557 558 f.MustGet(types.NamespacedName{Name: tf.Name}, tf) 559 } 560 561 func (f *fixture) triggerRun(name string) { 562 queue := configmap2.TriggerQueueCreate([]configmap2.TriggerQueueEntry{{Name: model.ManifestName(name)}}) 563 f.Create(&queue) 564 } 565 566 func (f *fixture) setArgs(name string, args []string) { 567 tf := v1alpha1.Tiltfile{ 568 ObjectMeta: metav1.ObjectMeta{ 569 Name: name, 570 }, 571 } 572 _, err := controllerutil.CreateOrUpdate(f.Context(), f.Client, &tf, func() error { 573 tf.Spec.Args = args 574 return nil 575 }) 576 require.NoError(f.T(), err) 577 } 578 579 func (f *fixture) requireEnabled(m model.Manifest, isEnabled bool) { 580 var cm v1alpha1.ConfigMap 581 f.MustGet(types.NamespacedName{Name: disableConfigMapName(m)}, &cm) 582 isDisabled, err := strconv.ParseBool(cm.Data["isDisabled"]) 583 require.NoError(f.T(), err) 584 actualIsEnabled := !isDisabled 585 require.Equal(f.T(), isEnabled, actualIsEnabled, "is %s enabled", m.Name) 586 } 587 588 // builds block until canceled or manually completed 589 type blockingTiltfileLoader struct { 590 completionChan chan struct{} 591 } 592 593 func newBlockingTiltfileLoader() blockingTiltfileLoader { 594 return blockingTiltfileLoader{completionChan: make(chan struct{})} 595 } 596 597 func (b blockingTiltfileLoader) Load(ctx context.Context, tf *v1alpha1.Tiltfile, prevResult *tiltfile.TiltfileLoadResult) tiltfile.TiltfileLoadResult { 598 select { 599 case <-ctx.Done(): 600 case <-b.completionChan: 601 } 602 return tiltfile.TiltfileLoadResult{} 603 } 604 605 func (b blockingTiltfileLoader) Complete() { 606 close(b.completionChan) 607 }