github.com/tilt-dev/tilt@v0.33.15-0.20240515162809-0a22ed45d8a0/internal/controllers/core/liveupdate/reconciler_test.go (about) 1 package liveupdate 2 3 import ( 4 "context" 5 "errors" 6 "fmt" 7 "os" 8 "path/filepath" 9 "testing" 10 "time" 11 12 "github.com/stretchr/testify/assert" 13 "github.com/stretchr/testify/require" 14 metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 15 "k8s.io/apimachinery/pkg/types" 16 "sigs.k8s.io/controller-runtime/pkg/reconcile" 17 18 "github.com/tilt-dev/tilt/internal/build" 19 "github.com/tilt-dev/tilt/internal/containerupdate" 20 "github.com/tilt-dev/tilt/internal/controllers/apis/configmap" 21 "github.com/tilt-dev/tilt/internal/controllers/apis/liveupdate" 22 "github.com/tilt-dev/tilt/internal/controllers/fake" 23 "github.com/tilt-dev/tilt/internal/dockercompose" 24 "github.com/tilt-dev/tilt/internal/store" 25 "github.com/tilt-dev/tilt/internal/store/buildcontrols" 26 "github.com/tilt-dev/tilt/pkg/apis" 27 "github.com/tilt-dev/tilt/pkg/apis/core/v1alpha1" 28 "github.com/tilt-dev/tilt/pkg/logger" 29 "github.com/tilt-dev/tilt/pkg/model" 30 ) 31 32 func TestIndexing(t *testing.T) { 33 f := newFixture(t) 34 35 // KubernetesDiscovery + KubernetesApply + ImageMap 36 f.Create(&v1alpha1.LiveUpdate{ 37 ObjectMeta: metav1.ObjectMeta{Name: "all"}, 38 Spec: v1alpha1.LiveUpdateSpec{ 39 BasePath: "/tmp", 40 Selector: v1alpha1.LiveUpdateSelector{ 41 Kubernetes: &v1alpha1.LiveUpdateKubernetesSelector{ 42 DiscoveryName: "discovery", 43 ApplyName: "apply", 44 ImageMapName: "imagemap", 45 }, 46 }, 47 Syncs: []v1alpha1.LiveUpdateSync{ 48 {LocalPath: "in", ContainerPath: "/out/"}, 49 }, 50 }, 51 }) 52 53 // KubernetesDiscovery ONLY [w/o Kubernetes Apply or ImageMap] 54 f.Create(&v1alpha1.LiveUpdate{ 55 ObjectMeta: metav1.ObjectMeta{Name: "kdisco-only"}, 56 Spec: v1alpha1.LiveUpdateSpec{ 57 BasePath: "/tmp", 58 Selector: v1alpha1.LiveUpdateSelector{ 59 Kubernetes: &v1alpha1.LiveUpdateKubernetesSelector{ 60 DiscoveryName: "discovery", 61 ContainerName: "foo", 62 }, 63 }, 64 Syncs: []v1alpha1.LiveUpdateSync{ 65 {LocalPath: "in", ContainerPath: "/out/"}, 66 }, 67 }, 68 }) 69 70 ctx := context.Background() 71 reqs := f.r.indexer.Enqueue(ctx, &v1alpha1.KubernetesDiscovery{ObjectMeta: metav1.ObjectMeta{Name: "discovery"}}) 72 require.ElementsMatch(t, []reconcile.Request{ 73 {NamespacedName: types.NamespacedName{Name: "all"}}, 74 {NamespacedName: types.NamespacedName{Name: "kdisco-only"}}, 75 }, reqs, "KubernetesDiscovery indexing") 76 77 reqs = f.r.indexer.Enqueue(ctx, &v1alpha1.KubernetesApply{ObjectMeta: metav1.ObjectMeta{Name: "apply"}}) 78 require.ElementsMatch(t, []reconcile.Request{ 79 {NamespacedName: types.NamespacedName{Name: "all"}}, 80 }, reqs, "KubernetesApply indexing") 81 82 reqs = f.r.indexer.Enqueue(ctx, &v1alpha1.ImageMap{ObjectMeta: metav1.ObjectMeta{Name: "imagemap"}}) 83 require.ElementsMatch(t, []reconcile.Request{ 84 {NamespacedName: types.NamespacedName{Name: "all"}}, 85 }, reqs, "ImageMap indexing") 86 } 87 88 func TestMissingApply(t *testing.T) { 89 f := newFixture(t) 90 91 f.setupFrontend() 92 f.Delete(&v1alpha1.KubernetesApply{ObjectMeta: metav1.ObjectMeta{Name: "frontend-apply"}}) 93 f.MustReconcile(types.NamespacedName{Name: "frontend-liveupdate"}) 94 95 var lu v1alpha1.LiveUpdate 96 f.MustGet(types.NamespacedName{Name: "frontend-liveupdate"}, &lu) 97 if assert.NotNil(t, lu.Status.Failed) { 98 assert.Equal(t, "ObjectNotFound", lu.Status.Failed.Reason) 99 assert.NotContains(t, f.Stdout(), "ObjectNotFound") 100 } 101 102 f.assertSteadyState(&lu) 103 } 104 105 func TestConsumeFileEvents(t *testing.T) { 106 f := newFixture(t) 107 108 p, _ := os.Getwd() 109 nowMicro := apis.NowMicro() 110 txtPath := filepath.Join(p, "a.txt") 111 txtChangeTime := metav1.MicroTime{Time: nowMicro.Add(time.Second)} 112 113 f.setupFrontend() 114 115 // Verify initial setup. 116 m, ok := f.r.monitors["frontend-liveupdate"] 117 require.True(t, ok) 118 assert.Equal(t, map[string]*monitorSource{}, m.sources) 119 assert.Equal(t, "frontend-discovery", m.lastKubernetesDiscovery.Name) 120 assert.Nil(t, f.st.lastStartedAction) 121 122 // Trigger a file event, and make sure that the status reflects the sync. 123 f.addFileEvent("frontend-fw", txtPath, txtChangeTime) 124 f.MustReconcile(types.NamespacedName{Name: "frontend-liveupdate"}) 125 126 var lu v1alpha1.LiveUpdate 127 f.MustGet(types.NamespacedName{Name: "frontend-liveupdate"}, &lu) 128 assert.Nil(t, lu.Status.Failed) 129 if assert.Equal(t, 1, len(lu.Status.Containers)) { 130 assert.Equal(t, txtChangeTime, lu.Status.Containers[0].LastFileTimeSynced) 131 } 132 133 // Also make sure the sync gets pulled into the monitor. 134 assert.Equal(t, map[string]metav1.MicroTime{ 135 txtPath: txtChangeTime, 136 }, m.sources["frontend-fw"].modTimeByPath) 137 assert.Equal(t, 1, len(f.cu.Calls)) 138 139 // re-reconcile, and make sure we don't try to resync. 140 f.MustReconcile(types.NamespacedName{Name: "frontend-liveupdate"}) 141 assert.Equal(t, 1, len(f.cu.Calls)) 142 143 f.MustGet(types.NamespacedName{Name: "frontend-liveupdate"}, &lu) 144 assert.Nil(t, lu.Status.Failed) 145 146 if assert.NotNil(t, f.st.lastStartedAction) { 147 assert.Equal(t, []string{txtPath}, f.st.lastStartedAction.FilesChanged) 148 } 149 assert.NotNil(t, f.st.lastCompletedAction) 150 } 151 152 func TestConsumeFileEventsDockerCompose(t *testing.T) { 153 f := newFixture(t) 154 155 p, _ := os.Getwd() 156 nowMicro := apis.NowMicro() 157 txtPath := filepath.Join(p, "a.txt") 158 txtChangeTime := metav1.MicroTime{Time: nowMicro.Add(time.Second)} 159 160 f.setupDockerComposeFrontend() 161 162 // Verify initial setup. 163 m, ok := f.r.monitors["frontend-liveupdate"] 164 require.True(t, ok) 165 assert.Equal(t, map[string]*monitorSource{}, m.sources) 166 assert.Equal(t, "frontend-service", m.lastDockerComposeService.Name) 167 assert.Nil(t, f.st.lastStartedAction) 168 169 // Trigger a file event, and make sure that the status reflects the sync. 170 f.addFileEvent("frontend-fw", txtPath, txtChangeTime) 171 f.MustReconcile(types.NamespacedName{Name: "frontend-liveupdate"}) 172 173 var lu v1alpha1.LiveUpdate 174 f.MustGet(types.NamespacedName{Name: "frontend-liveupdate"}, &lu) 175 assert.Nil(t, lu.Status.Failed) 176 if assert.Equal(t, 1, len(lu.Status.Containers)) { 177 assert.Equal(t, txtChangeTime, lu.Status.Containers[0].LastFileTimeSynced) 178 } 179 180 // Also make sure the sync gets pulled into the monitor. 181 assert.Equal(t, map[string]metav1.MicroTime{ 182 txtPath: txtChangeTime, 183 }, m.sources["frontend-fw"].modTimeByPath) 184 assert.Equal(t, 1, len(f.cu.Calls)) 185 186 // re-reconcile, and make sure we don't try to resync. 187 f.MustReconcile(types.NamespacedName{Name: "frontend-liveupdate"}) 188 assert.Equal(t, 1, len(f.cu.Calls)) 189 190 f.MustGet(types.NamespacedName{Name: "frontend-liveupdate"}, &lu) 191 assert.Nil(t, lu.Status.Failed) 192 193 if assert.NotNil(t, f.st.lastStartedAction) { 194 assert.Equal(t, []string{txtPath}, f.st.lastStartedAction.FilesChanged) 195 } 196 assert.NotNil(t, f.st.lastCompletedAction) 197 198 // Make sure the container was NOT restarted. 199 if assert.Equal(t, 1, len(f.cu.Calls)) { 200 assert.True(t, f.cu.Calls[0].HotReload) 201 } 202 203 f.assertSteadyState(&lu) 204 205 // Docker Compose containers can be restarted in-place, 206 // preserving their filesystem. 207 dc := &v1alpha1.DockerComposeService{} 208 f.MustGet(types.NamespacedName{Name: "frontend-service"}, dc) 209 dc = dc.DeepCopy() 210 dc.Status.ContainerState.StartedAt = apis.NowMicro() 211 f.UpdateStatus(dc) 212 213 f.assertSteadyState(&lu) 214 } 215 216 func TestConsumeFileEventsUpdateModeManual(t *testing.T) { 217 f := newFixture(t) 218 219 p, _ := os.Getwd() 220 nowMicro := apis.NowMicro() 221 txtPath := filepath.Join(p, "a.txt") 222 txtChangeTime := metav1.MicroTime{Time: nowMicro.Add(time.Second)} 223 224 f.setupFrontend() 225 226 var lu v1alpha1.LiveUpdate 227 f.MustGet(types.NamespacedName{Name: "frontend-liveupdate"}, &lu) 228 lu.Annotations[liveupdate.AnnotationUpdateMode] = liveupdate.UpdateModeManual 229 f.Update(&lu) 230 231 // Trigger a file event, and make sure that the status reflects the sync. 232 f.addFileEvent("frontend-fw", txtPath, txtChangeTime) 233 f.MustReconcile(types.NamespacedName{Name: "frontend-liveupdate"}) 234 235 f.MustGet(types.NamespacedName{Name: "frontend-liveupdate"}, &lu) 236 assert.Nil(t, lu.Status.Failed) 237 if assert.Equal(t, 1, len(lu.Status.Containers)) { 238 assert.Equal(t, "Trigger", lu.Status.Containers[0].Waiting.Reason) 239 } 240 241 f.Upsert(&v1alpha1.ConfigMap{ 242 ObjectMeta: metav1.ObjectMeta{ 243 Name: configmap.TriggerQueueName, 244 }, 245 Data: map[string]string{ 246 "0-name": "frontend", 247 }, 248 }) 249 250 f.MustReconcile(types.NamespacedName{Name: "frontend-liveupdate"}) 251 252 f.MustGet(types.NamespacedName{Name: "frontend-liveupdate"}, &lu) 253 assert.Nil(t, lu.Status.Failed) 254 if assert.Equal(t, 1, len(lu.Status.Containers)) { 255 assert.Equal(t, txtChangeTime, lu.Status.Containers[0].LastFileTimeSynced) 256 } 257 } 258 259 func TestWaitingContainer(t *testing.T) { 260 f := newFixture(t) 261 262 p, _ := os.Getwd() 263 nowMicro := apis.NowMicro() 264 txtPath := filepath.Join(p, "a.txt") 265 txtChangeTime := metav1.MicroTime{Time: nowMicro.Add(time.Second)} 266 267 f.setupFrontend() 268 f.kdUpdateStatus("frontend-discovery", v1alpha1.KubernetesDiscoveryStatus{ 269 Pods: []v1alpha1.Pod{ 270 { 271 Name: "pod-1", 272 Namespace: "default", 273 Containers: []v1alpha1.Container{ 274 { 275 Name: "main", 276 ID: "main-id", 277 Image: "local-registry:12345/frontend-image:my-tag", 278 State: v1alpha1.ContainerState{ 279 Waiting: &v1alpha1.ContainerStateWaiting{}, 280 }, 281 }, 282 }, 283 }, 284 }, 285 }) 286 287 f.addFileEvent("frontend-fw", txtPath, txtChangeTime) 288 f.MustReconcile(types.NamespacedName{Name: "frontend-liveupdate"}) 289 290 var lu v1alpha1.LiveUpdate 291 f.MustGet(types.NamespacedName{Name: "frontend-liveupdate"}, &lu) 292 assert.Nil(t, lu.Status.Failed) 293 if assert.Equal(t, 1, len(lu.Status.Containers)) { 294 assert.Equal(t, "ContainerWaiting", lu.Status.Containers[0].Waiting.Reason) 295 } 296 assert.Equal(t, 0, len(f.cu.Calls)) 297 298 f.assertSteadyState(&lu) 299 300 f.kdUpdateStatus("frontend-discovery", v1alpha1.KubernetesDiscoveryStatus{ 301 Pods: []v1alpha1.Pod{ 302 { 303 Name: "pod-1", 304 Namespace: "default", 305 Containers: []v1alpha1.Container{ 306 { 307 Name: "main", 308 ID: "main-id", 309 Image: "local-registry:12345/frontend-image:my-tag", 310 State: v1alpha1.ContainerState{ 311 Running: &v1alpha1.ContainerStateRunning{}, 312 }, 313 }, 314 }, 315 }, 316 }, 317 }) 318 319 // Re-reconcile, and make sure we pull in the files. 320 f.MustReconcile(types.NamespacedName{Name: "frontend-liveupdate"}) 321 assert.Equal(t, 1, len(f.cu.Calls)) 322 } 323 324 func TestWaitingContainerNoID(t *testing.T) { 325 f := newFixture(t) 326 327 p, _ := os.Getwd() 328 nowMicro := apis.NowMicro() 329 txtPath := filepath.Join(p, "a.txt") 330 txtChangeTime := metav1.MicroTime{Time: nowMicro.Add(time.Second)} 331 332 f.setupFrontend() 333 f.kdUpdateStatus("frontend-discovery", v1alpha1.KubernetesDiscoveryStatus{ 334 Pods: []v1alpha1.Pod{ 335 { 336 Name: "pod-1", 337 Namespace: "default", 338 InitContainers: []v1alpha1.Container{ 339 { 340 Name: "main-init", 341 ID: "main-id", 342 Image: "busybox", 343 State: v1alpha1.ContainerState{ 344 Running: &v1alpha1.ContainerStateRunning{}, 345 }, 346 }, 347 }, 348 Containers: []v1alpha1.Container{ 349 { 350 Name: "main", 351 Image: "local-registry:12345/frontend-image:my-tag", 352 State: v1alpha1.ContainerState{ 353 Waiting: &v1alpha1.ContainerStateWaiting{Reason: "PodInitializing"}, 354 }, 355 }, 356 }, 357 }, 358 }, 359 }) 360 361 f.addFileEvent("frontend-fw", txtPath, txtChangeTime) 362 f.MustReconcile(types.NamespacedName{Name: "frontend-liveupdate"}) 363 364 var lu v1alpha1.LiveUpdate 365 f.MustGet(types.NamespacedName{Name: "frontend-liveupdate"}, &lu) 366 assert.Nil(t, lu.Status.Failed) 367 if assert.Equal(t, 1, len(lu.Status.Containers)) { 368 assert.Equal(t, "ContainerWaiting", lu.Status.Containers[0].Waiting.Reason) 369 } 370 assert.Equal(t, 0, len(f.cu.Calls)) 371 372 f.assertSteadyState(&lu) 373 } 374 375 func TestOneTerminatedContainer(t *testing.T) { 376 f := newFixture(t) 377 378 p, _ := os.Getwd() 379 nowMicro := apis.NowMicro() 380 txtPath := filepath.Join(p, "a.txt") 381 txtChangeTime := metav1.MicroTime{Time: nowMicro.Add(time.Second)} 382 383 f.setupFrontend() 384 f.kdUpdateStatus("frontend-discovery", v1alpha1.KubernetesDiscoveryStatus{ 385 Pods: []v1alpha1.Pod{ 386 { 387 Name: "pod-1", 388 Namespace: "default", 389 Containers: []v1alpha1.Container{ 390 { 391 Name: "main", 392 ID: "main-id", 393 Image: "local-registry:12345/frontend-image:my-tag", 394 State: v1alpha1.ContainerState{ 395 Terminated: &v1alpha1.ContainerStateTerminated{}, 396 }, 397 }, 398 }, 399 }, 400 }, 401 }) 402 403 f.addFileEvent("frontend-fw", txtPath, txtChangeTime) 404 f.MustReconcile(types.NamespacedName{Name: "frontend-liveupdate"}) 405 406 var lu v1alpha1.LiveUpdate 407 f.MustGet(types.NamespacedName{Name: "frontend-liveupdate"}, &lu) 408 if assert.NotNil(t, lu.Status.Failed) { 409 assert.Equal(t, "Terminated", lu.Status.Failed.Reason) 410 assert.Contains(t, f.Stdout(), 411 `LiveUpdate "frontend-liveupdate" Terminated: Container for live update is stopped. Pod name: pod-1`) 412 } 413 414 f.assertSteadyState(&lu) 415 } 416 417 func TestOneRunningOneTerminatedContainer(t *testing.T) { 418 f := newFixture(t) 419 420 p, _ := os.Getwd() 421 nowMicro := apis.NowMicro() 422 txtPath := filepath.Join(p, "a.txt") 423 txtChangeTime := metav1.MicroTime{Time: nowMicro.Add(time.Second)} 424 425 f.setupFrontend() 426 f.kdUpdateStatus("frontend-discovery", v1alpha1.KubernetesDiscoveryStatus{ 427 Pods: []v1alpha1.Pod{ 428 { 429 Name: "pod-1", 430 Namespace: "default", 431 Containers: []v1alpha1.Container{ 432 { 433 Name: "main", 434 ID: "main-id", 435 Image: "local-registry:12345/frontend-image:my-tag", 436 State: v1alpha1.ContainerState{ 437 Terminated: &v1alpha1.ContainerStateTerminated{}, 438 }, 439 }, 440 }, 441 }, 442 { 443 Name: "pod-2", 444 Namespace: "default", 445 Containers: []v1alpha1.Container{ 446 { 447 Name: "main", 448 ID: "main-id", 449 Image: "local-registry:12345/frontend-image:my-tag", 450 State: v1alpha1.ContainerState{ 451 Running: &v1alpha1.ContainerStateRunning{}, 452 }, 453 }, 454 }, 455 }, 456 }, 457 }) 458 459 // Trigger a file event, and make sure that the status reflects the sync. 460 f.addFileEvent("frontend-fw", txtPath, txtChangeTime) 461 f.MustReconcile(types.NamespacedName{Name: "frontend-liveupdate"}) 462 463 var lu v1alpha1.LiveUpdate 464 f.MustGet(types.NamespacedName{Name: "frontend-liveupdate"}, &lu) 465 assert.Nil(t, lu.Status.Failed) 466 if assert.Equal(t, 1, len(lu.Status.Containers)) { 467 assert.Equal(t, txtChangeTime, lu.Status.Containers[0].LastFileTimeSynced) 468 } 469 470 // Also make sure the sync gets pulled into the monitor. 471 m, ok := f.r.monitors["frontend-liveupdate"] 472 require.True(t, ok) 473 assert.Equal(t, map[string]metav1.MicroTime{ 474 txtPath: txtChangeTime, 475 }, m.sources["frontend-fw"].modTimeByPath) 476 assert.Equal(t, 1, len(f.cu.Calls)) 477 assert.Equal(t, "pod-2", f.cu.Calls[0].ContainerInfo.PodID.String()) 478 479 f.assertSteadyState(&lu) 480 } 481 482 func TestCrashLoopBackoff(t *testing.T) { 483 f := newFixture(t) 484 485 p, _ := os.Getwd() 486 nowMicro := apis.NowMicro() 487 txtPath := filepath.Join(p, "a.txt") 488 txtChangeTime := metav1.MicroTime{Time: nowMicro.Add(time.Second)} 489 490 f.setupFrontend() 491 f.kdUpdateStatus("frontend-discovery", v1alpha1.KubernetesDiscoveryStatus{ 492 Pods: []v1alpha1.Pod{ 493 { 494 Name: "pod-1", 495 Namespace: "default", 496 Containers: []v1alpha1.Container{ 497 { 498 Name: "main", 499 ID: "main-id", 500 Image: "local-registry:12345/frontend-image:my-tag", 501 State: v1alpha1.ContainerState{ 502 Waiting: &v1alpha1.ContainerStateWaiting{Reason: "CrashLoopBackOff"}, 503 }, 504 }, 505 }, 506 }, 507 }, 508 }) 509 510 f.addFileEvent("frontend-fw", txtPath, txtChangeTime) 511 f.MustReconcile(types.NamespacedName{Name: "frontend-liveupdate"}) 512 513 var lu v1alpha1.LiveUpdate 514 f.MustGet(types.NamespacedName{Name: "frontend-liveupdate"}, &lu) 515 if assert.NotNil(t, lu.Status.Failed) { 516 assert.Equal(t, "CrashLoopBackOff", lu.Status.Failed.Reason) 517 } 518 assert.Equal(t, 0, len(f.cu.Calls)) 519 520 f.assertSteadyState(&lu) 521 522 f.kdUpdateStatus("frontend-discovery", v1alpha1.KubernetesDiscoveryStatus{ 523 Pods: []v1alpha1.Pod{ 524 { 525 Name: "pod-1", 526 Namespace: "default", 527 Containers: []v1alpha1.Container{ 528 { 529 Name: "main", 530 ID: "main-id", 531 Image: "local-registry:12345/frontend-image:my-tag", 532 State: v1alpha1.ContainerState{ 533 Running: &v1alpha1.ContainerStateRunning{}, 534 }, 535 }, 536 }, 537 }, 538 }, 539 }) 540 541 // CrashLoopBackOff is a permanent state. If the container starts running 542 // again, we don't "revive" the live-update. 543 f.MustReconcile(types.NamespacedName{Name: "frontend-liveupdate"}) 544 f.MustGet(types.NamespacedName{Name: "frontend-liveupdate"}, &lu) 545 if assert.NotNil(t, lu.Status.Failed) { 546 assert.Equal(t, "CrashLoopBackOff", lu.Status.Failed.Reason) 547 } 548 } 549 550 func TestStopPathConsumedByImageBuild(t *testing.T) { 551 f := newFixture(t) 552 553 p, _ := os.Getwd() 554 nowMicro := apis.NowMicro() 555 stopPath := filepath.Join(p, "stop.txt") 556 stopChangeTime := metav1.MicroTime{Time: nowMicro.Add(time.Second)} 557 558 f.setupFrontend() 559 560 f.addFileEvent("frontend-fw", stopPath, stopChangeTime) 561 f.MustReconcile(types.NamespacedName{Name: "frontend-liveupdate"}) 562 563 var lu v1alpha1.LiveUpdate 564 f.MustGet(types.NamespacedName{Name: "frontend-liveupdate"}, &lu) 565 if assert.NotNil(t, lu.Status.Failed) { 566 assert.Equal(t, "UpdateStopped", lu.Status.Failed.Reason) 567 } 568 569 f.assertSteadyState(&lu) 570 571 // Clear the failure with an Image build 572 f.Upsert(&v1alpha1.ImageMap{ 573 ObjectMeta: metav1.ObjectMeta{Name: "frontend-image-map"}, 574 Status: v1alpha1.ImageMapStatus{ 575 Image: "frontend-image:my-tag", 576 ImageFromCluster: "local-registry:12345/frontend-image:my-tag", 577 BuildStartTime: &metav1.MicroTime{Time: nowMicro.Add(2 * time.Second)}, 578 }, 579 }) 580 581 f.MustReconcile(types.NamespacedName{Name: "frontend-liveupdate"}) 582 f.MustGet(types.NamespacedName{Name: "frontend-liveupdate"}, &lu) 583 assert.Nil(t, lu.Status.Failed) 584 585 txtPath := filepath.Join(p, "a.txt") 586 txtChangeTime := metav1.MicroTime{Time: nowMicro.Add(3 * time.Second)} 587 f.addFileEvent("frontend-fw", txtPath, txtChangeTime) 588 589 assert.Equal(t, 0, len(f.cu.Calls)) 590 f.MustReconcile(types.NamespacedName{Name: "frontend-liveupdate"}) 591 assert.Equal(t, 1, len(f.cu.Calls)) 592 } 593 594 func TestStopPathConsumedByKubernetesApply(t *testing.T) { 595 f := newFixture(t) 596 597 p, _ := os.Getwd() 598 nowMicro := apis.NowMicro() 599 stopPath := filepath.Join(p, "stop.txt") 600 stopChangeTime := metav1.MicroTime{Time: nowMicro.Add(time.Second)} 601 602 // we are going to delete the ImageMap, so we cannot use it as a selector 603 // (the default behavior) 604 f.setupFrontendWithSelector(&v1alpha1.LiveUpdateSelector{ 605 Kubernetes: &v1alpha1.LiveUpdateKubernetesSelector{ 606 DiscoveryName: "frontend-discovery", 607 ApplyName: "frontend-apply", 608 Image: "local-registry:12345/frontend-image:some-tag", 609 }, 610 }) 611 f.Delete(&v1alpha1.ImageMap{ 612 ObjectMeta: metav1.ObjectMeta{Name: "frontend-image-map"}, 613 }) 614 615 var lu v1alpha1.LiveUpdate 616 f.MustGet(types.NamespacedName{Name: "frontend-liveupdate"}, &lu) 617 lu.Spec.Sources[0].ImageMap = "" 618 f.Update(&lu) 619 620 f.addFileEvent("frontend-fw", stopPath, stopChangeTime) 621 f.MustReconcile(types.NamespacedName{Name: "frontend-liveupdate"}) 622 623 f.MustGet(types.NamespacedName{Name: "frontend-liveupdate"}, &lu) 624 if assert.NotNil(t, lu.Status.Failed) { 625 assert.Equal(t, "UpdateStopped", lu.Status.Failed.Reason) 626 } 627 628 f.assertSteadyState(&lu) 629 630 // Clear the failure with an Apply 631 f.Upsert(&v1alpha1.KubernetesApply{ 632 ObjectMeta: metav1.ObjectMeta{Name: "frontend-apply"}, 633 Status: v1alpha1.KubernetesApplyStatus{ 634 LastApplyStartTime: metav1.MicroTime{Time: nowMicro.Add(2 * time.Second)}, 635 }, 636 }) 637 638 f.MustReconcile(types.NamespacedName{Name: "frontend-liveupdate"}) 639 f.MustGet(types.NamespacedName{Name: "frontend-liveupdate"}, &lu) 640 assert.Nil(t, lu.Status.Failed) 641 642 txtPath := filepath.Join(p, "a.txt") 643 txtChangeTime := metav1.MicroTime{Time: nowMicro.Add(3 * time.Second)} 644 f.addFileEvent("frontend-fw", txtPath, txtChangeTime) 645 646 assert.Equal(t, 0, len(f.cu.Calls)) 647 f.MustReconcile(types.NamespacedName{Name: "frontend-liveupdate"}) 648 assert.Equal(t, 1, len(f.cu.Calls)) 649 } 650 651 func TestKubernetesContainerNameSelector(t *testing.T) { 652 f := newFixture(t) 653 654 p, _ := os.Getwd() 655 nowMicro := apis.NowMicro() 656 txtPath := filepath.Join(p, "a.txt") 657 txtChangeTime := metav1.MicroTime{Time: nowMicro.Add(time.Second)} 658 659 // change from default ImageMap selector to a container name selector 660 f.setupFrontendWithSelector(&v1alpha1.LiveUpdateSelector{ 661 Kubernetes: &v1alpha1.LiveUpdateKubernetesSelector{ 662 DiscoveryName: "frontend-discovery", 663 ApplyName: "frontend-apply", 664 ContainerName: "main", 665 }, 666 }) 667 668 f.kdUpdateStatus("frontend-discovery", v1alpha1.KubernetesDiscoveryStatus{ 669 Pods: []v1alpha1.Pod{ 670 { 671 Name: "pod-1", 672 Namespace: "default", 673 Containers: []v1alpha1.Container{ 674 { 675 Name: "main", 676 ID: "main-id", 677 Image: "frontend-image", 678 State: v1alpha1.ContainerState{ 679 Running: &v1alpha1.ContainerStateRunning{}, 680 }, 681 }, 682 }, 683 }, 684 }, 685 }) 686 687 // Trigger a file event, and make sure that the status reflects the sync. 688 f.addFileEvent("frontend-fw", txtPath, txtChangeTime) 689 f.MustReconcile(types.NamespacedName{Name: "frontend-liveupdate"}) 690 691 var lu v1alpha1.LiveUpdate 692 f.MustGet(types.NamespacedName{Name: "frontend-liveupdate"}, &lu) 693 assert.Equal(t, "main", lu.Spec.Selector.Kubernetes.ContainerName) 694 assert.Nil(t, lu.Status.Failed) 695 if assert.Equal(t, 1, len(lu.Status.Containers)) { 696 assert.Equal(t, txtChangeTime, lu.Status.Containers[0].LastFileTimeSynced) 697 } 698 699 f.assertSteadyState(&lu) 700 } 701 702 func TestKubernetesImageSelector(t *testing.T) { 703 f := newFixture(t) 704 705 p, _ := os.Getwd() 706 nowMicro := apis.NowMicro() 707 txtPath := filepath.Join(p, "a.txt") 708 txtChangeTime := metav1.MicroTime{Time: nowMicro.Add(time.Second)} 709 710 // change from default ImageMap selector to an image selector 711 f.setupFrontendWithSelector(&v1alpha1.LiveUpdateSelector{ 712 Kubernetes: &v1alpha1.LiveUpdateKubernetesSelector{ 713 DiscoveryName: "frontend-discovery", 714 ApplyName: "frontend-apply", 715 Image: "local-registry:12345/frontend-image:some-tag", 716 }, 717 }) 718 719 f.kdUpdateStatus("frontend-discovery", v1alpha1.KubernetesDiscoveryStatus{ 720 Pods: []v1alpha1.Pod{ 721 { 722 Name: "pod-1", 723 Namespace: "default", 724 Containers: []v1alpha1.Container{ 725 { 726 Name: "main", 727 ID: "main-id", 728 Image: "local-registry:12345/frontend-image:my-tag", 729 State: v1alpha1.ContainerState{ 730 Running: &v1alpha1.ContainerStateRunning{}, 731 }, 732 }, 733 }, 734 }, 735 }, 736 }) 737 738 // Trigger a file event, and make sure that the status reflects the sync. 739 f.addFileEvent("frontend-fw", txtPath, txtChangeTime) 740 f.MustReconcile(types.NamespacedName{Name: "frontend-liveupdate"}) 741 742 var lu v1alpha1.LiveUpdate 743 f.MustGet(types.NamespacedName{Name: "frontend-liveupdate"}, &lu) 744 assert.Equal(t, "local-registry:12345/frontend-image:some-tag", 745 lu.Spec.Selector.Kubernetes.Image) 746 assert.Nil(t, lu.Status.Failed) 747 if assert.Equal(t, 1, len(lu.Status.Containers)) { 748 assert.Equal(t, txtChangeTime, lu.Status.Containers[0].LastFileTimeSynced) 749 } 750 751 f.assertSteadyState(&lu) 752 } 753 754 func TestDockerComposeRestartPolicy(t *testing.T) { 755 f := newFixture(t) 756 757 p, _ := os.Getwd() 758 nowMicro := apis.NowMicro() 759 txtPath := filepath.Join(p, "a.txt") 760 txtChangeTime := metav1.MicroTime{Time: nowMicro.Add(time.Second)} 761 762 f.setupDockerComposeFrontend() 763 764 var lu v1alpha1.LiveUpdate 765 f.MustGet(types.NamespacedName{Name: "frontend-liveupdate"}, &lu) 766 lu.Spec.Restart = v1alpha1.LiveUpdateRestartStrategyAlways 767 f.Upsert(&lu) 768 769 // Trigger a file event, and make sure that the status reflects the sync. 770 f.addFileEvent("frontend-fw", txtPath, txtChangeTime) 771 f.MustReconcile(types.NamespacedName{Name: "frontend-liveupdate"}) 772 773 f.MustGet(types.NamespacedName{Name: "frontend-liveupdate"}, &lu) 774 assert.Nil(t, lu.Status.Failed) 775 if assert.Equal(t, 1, len(lu.Status.Containers)) { 776 assert.Equal(t, txtChangeTime, lu.Status.Containers[0].LastFileTimeSynced) 777 } 778 779 // Make sure the container was restarted. 780 if assert.Equal(t, 1, len(f.cu.Calls)) { 781 assert.False(t, f.cu.Calls[0].HotReload) 782 } 783 } 784 785 func TestDockerComposeExecs(t *testing.T) { 786 f := newFixture(t) 787 788 p, _ := os.Getwd() 789 nowMicro := apis.NowMicro() 790 txtPath := filepath.Join(p, "a.txt") 791 txtChangeTime := metav1.MicroTime{Time: nowMicro.Add(time.Second)} 792 793 f.setupDockerComposeFrontend() 794 795 var lu v1alpha1.LiveUpdate 796 f.MustGet(types.NamespacedName{Name: "frontend-liveupdate"}, &lu) 797 798 execs := []v1alpha1.LiveUpdateExec{ 799 {Args: model.ToUnixCmd("./foo.sh bar").Argv}, 800 {Args: model.ToUnixCmd("yarn install").Argv, TriggerPaths: []string{"a.txt"}}, 801 {Args: model.ToUnixCmd("pip install").Argv, TriggerPaths: []string{"requirements.txt"}}, 802 } 803 lu.Spec.Execs = execs 804 f.Upsert(&lu) 805 806 // Trigger a file event, and make sure that the status reflects the sync. 807 f.addFileEvent("frontend-fw", txtPath, txtChangeTime) 808 f.MustReconcile(types.NamespacedName{Name: "frontend-liveupdate"}) 809 810 f.MustGet(types.NamespacedName{Name: "frontend-liveupdate"}, &lu) 811 assert.Nil(t, lu.Status.Failed) 812 if assert.Equal(t, 1, len(lu.Status.Containers)) { 813 assert.Equal(t, txtChangeTime, lu.Status.Containers[0].LastFileTimeSynced) 814 } 815 816 // Make sure there were no exec errors. 817 if assert.NotNil(t, f.st.lastCompletedAction) { 818 assert.Nil(t, f.st.lastCompletedAction.Error) 819 } 820 821 // Make sure two cmds were executed, and one was skipped. 822 if assert.Equal(t, 1, len(f.cu.Calls)) { 823 assert.Equal(t, []model.Cmd{ 824 model.ToUnixCmd("./foo.sh bar"), 825 model.ToUnixCmd("yarn install"), 826 }, f.cu.Calls[0].Cmds) 827 } 828 } 829 830 func TestDockerComposeExecInfraFailure(t *testing.T) { 831 f := newFixture(t) 832 833 p, _ := os.Getwd() 834 nowMicro := apis.NowMicro() 835 txtPath := filepath.Join(p, "a.txt") 836 txtChangeTime := metav1.MicroTime{Time: nowMicro.Add(time.Second)} 837 838 f.setupDockerComposeFrontend() 839 840 var lu v1alpha1.LiveUpdate 841 f.MustGet(types.NamespacedName{Name: "frontend-liveupdate"}, &lu) 842 843 execs := []v1alpha1.LiveUpdateExec{ 844 {Args: model.ToUnixCmd("echo error && exit 1").Argv}, 845 } 846 lu.Spec.Execs = execs 847 f.Upsert(&lu) 848 849 f.cu.SetUpdateErr(fmt.Errorf("cluster connection lost")) 850 851 // Trigger a file event, and make sure that the status reflects the sync. 852 f.addFileEvent("frontend-fw", txtPath, txtChangeTime) 853 f.MustReconcile(types.NamespacedName{Name: "frontend-liveupdate"}) 854 855 f.MustGet(types.NamespacedName{Name: "frontend-liveupdate"}, &lu) 856 if assert.NotNil(t, lu.Status.Failed) { 857 assert.Equal(t, "UpdateFailed", lu.Status.Failed.Reason) 858 assert.Equal(t, "Updating container main-id: cluster connection lost", 859 lu.Status.Failed.Message) 860 } 861 862 // Make sure there were exec errors. 863 if assert.NotNil(t, f.st.lastCompletedAction) { 864 assert.Equal(t, "Updating container main-id: cluster connection lost", 865 f.st.lastCompletedAction.Error.Error()) 866 } 867 } 868 869 func TestDockerComposeExecRunFailure(t *testing.T) { 870 f := newFixture(t) 871 872 p, _ := os.Getwd() 873 nowMicro := apis.NowMicro() 874 txtPath := filepath.Join(p, "a.txt") 875 txtChangeTime := metav1.MicroTime{Time: nowMicro.Add(time.Second)} 876 877 f.setupDockerComposeFrontend() 878 879 var lu v1alpha1.LiveUpdate 880 f.MustGet(types.NamespacedName{Name: "frontend-liveupdate"}, &lu) 881 882 execs := []v1alpha1.LiveUpdateExec{ 883 {Args: model.ToUnixCmd("echo error && exit 1").Argv}, 884 } 885 lu.Spec.Execs = execs 886 f.Upsert(&lu) 887 888 f.cu.SetUpdateErr(build.NewRunStepFailure(errors.New("compilation failed"))) 889 890 // Trigger a file event, and make sure that the status reflects the sync. 891 f.addFileEvent("frontend-fw", txtPath, txtChangeTime) 892 f.MustReconcile(types.NamespacedName{Name: "frontend-liveupdate"}) 893 894 f.MustGet(types.NamespacedName{Name: "frontend-liveupdate"}, &lu) 895 assert.Nil(t, lu.Status.Failed) 896 if assert.Equal(t, 1, len(lu.Status.Containers)) { 897 assert.Equal(t, "compilation failed", lu.Status.Containers[0].LastExecError) 898 } 899 900 // Make sure there were exec errors. 901 if assert.NotNil(t, f.st.lastCompletedAction) { 902 assert.Equal(t, "compilation failed", 903 f.st.lastCompletedAction.Error.Error()) 904 } 905 } 906 907 type TestingStore struct { 908 *store.TestingStore 909 ctx context.Context 910 lastStartedAction *buildcontrols.BuildStartedAction 911 lastCompletedAction *buildcontrols.BuildCompleteAction 912 } 913 914 func newTestingStore() *TestingStore { 915 return &TestingStore{TestingStore: store.NewTestingStore()} 916 } 917 918 func (s *TestingStore) Dispatch(action store.Action) { 919 s.TestingStore.Dispatch(action) 920 switch action := action.(type) { 921 case buildcontrols.BuildStartedAction: 922 s.lastStartedAction = &action 923 case buildcontrols.BuildCompleteAction: 924 s.lastCompletedAction = &action 925 case store.LogAction: 926 _, _ = logger.Get(s.ctx).Writer(action.Level()).Write(action.Message()) 927 } 928 } 929 930 type fixture struct { 931 *fake.ControllerFixture 932 r *Reconciler 933 cu *containerupdate.FakeContainerUpdater 934 st *TestingStore 935 } 936 937 func newFixture(t testing.TB) *fixture { 938 cfb := fake.NewControllerFixtureBuilder(t) 939 cu := &containerupdate.FakeContainerUpdater{} 940 st := newTestingStore() 941 r := NewFakeReconciler(st, cu, cfb.Client) 942 cf := cfb.Build(r) 943 st.ctx = cf.Context() 944 return &fixture{ 945 ControllerFixture: cf, 946 r: r, 947 cu: cu, 948 st: st, 949 } 950 } 951 952 func (f *fixture) addFileEvent(name string, p string, time metav1.MicroTime) { 953 var fw v1alpha1.FileWatch 954 f.MustGet(types.NamespacedName{Name: name}, &fw) 955 update := fw.DeepCopy() 956 update.Status.FileEvents = append(update.Status.FileEvents, v1alpha1.FileEvent{ 957 Time: time, 958 SeenFiles: []string{p}, 959 }) 960 f.UpdateStatus(update) 961 } 962 963 func (f *fixture) setupFrontend() { 964 f.setupFrontendWithSelector(nil) 965 } 966 967 // Create a frontend LiveUpdate with all objects attached. 968 func (f *fixture) setupFrontendWithSelector(selector *v1alpha1.LiveUpdateSelector) { 969 p, _ := os.Getwd() 970 now := apis.Now() 971 nowMicro := apis.NowMicro() 972 973 // Create all the objects. 974 f.Create(&v1alpha1.FileWatch{ 975 ObjectMeta: metav1.ObjectMeta{Name: "frontend-fw"}, 976 Spec: v1alpha1.FileWatchSpec{ 977 WatchedPaths: []string{p}, 978 }, 979 Status: v1alpha1.FileWatchStatus{ 980 MonitorStartTime: nowMicro, 981 }, 982 }) 983 f.Create(&v1alpha1.KubernetesApply{ 984 ObjectMeta: metav1.ObjectMeta{Name: "frontend-apply"}, 985 Status: v1alpha1.KubernetesApplyStatus{}, 986 }) 987 f.Create(&v1alpha1.ImageMap{ 988 ObjectMeta: metav1.ObjectMeta{Name: "frontend-image-map"}, 989 Status: v1alpha1.ImageMapStatus{ 990 Image: "frontend-image:my-tag", 991 ImageFromCluster: "local-registry:12345/frontend-image:my-tag", 992 BuildStartTime: &nowMicro, 993 }, 994 }) 995 f.Create(&v1alpha1.KubernetesDiscovery{ 996 ObjectMeta: metav1.ObjectMeta{Name: "frontend-discovery"}, 997 Status: v1alpha1.KubernetesDiscoveryStatus{ 998 MonitorStartTime: nowMicro, 999 Pods: []v1alpha1.Pod{ 1000 { 1001 Name: "pod-1", 1002 Namespace: "default", 1003 Containers: []v1alpha1.Container{ 1004 { 1005 Name: "main", 1006 ID: "main-id", 1007 Image: "local-registry:12345/frontend-image:my-tag", 1008 Ready: true, 1009 State: v1alpha1.ContainerState{ 1010 Running: &v1alpha1.ContainerStateRunning{ 1011 StartedAt: now, 1012 }, 1013 }, 1014 }, 1015 }, 1016 }, 1017 }, 1018 }, 1019 }) 1020 1021 if selector == nil { 1022 // default selector matches the most common Tilt use-case, which has 1023 // KDisco + KApply and selects via ImageMap 1024 selector = &v1alpha1.LiveUpdateSelector{ 1025 Kubernetes: &v1alpha1.LiveUpdateKubernetesSelector{ 1026 ApplyName: "frontend-apply", 1027 DiscoveryName: "frontend-discovery", 1028 ImageMapName: "frontend-image-map", 1029 }, 1030 } 1031 } 1032 1033 f.Create(&v1alpha1.LiveUpdate{ 1034 ObjectMeta: metav1.ObjectMeta{ 1035 Name: "frontend-liveupdate", 1036 Annotations: map[string]string{ 1037 v1alpha1.AnnotationManifest: "frontend", 1038 liveupdate.AnnotationUpdateMode: "auto", 1039 }, 1040 }, 1041 Spec: v1alpha1.LiveUpdateSpec{ 1042 BasePath: p, 1043 Sources: []v1alpha1.LiveUpdateSource{{ 1044 FileWatch: "frontend-fw", 1045 ImageMap: "frontend-image-map", 1046 }}, 1047 Selector: *selector, 1048 Syncs: []v1alpha1.LiveUpdateSync{ 1049 {LocalPath: ".", ContainerPath: "/app"}, 1050 }, 1051 StopPaths: []string{"stop.txt"}, 1052 }, 1053 }) 1054 f.Create(&v1alpha1.ConfigMap{ 1055 ObjectMeta: metav1.ObjectMeta{ 1056 Name: configmap.TriggerQueueName, 1057 }, 1058 }) 1059 } 1060 1061 // Create a frontend DockerCompose LiveUpdate with all objects attached. 1062 func (f *fixture) setupDockerComposeFrontend() { 1063 p, _ := os.Getwd() 1064 nowMicro := apis.NowMicro() 1065 1066 // Create all the objects. 1067 f.Create(&v1alpha1.FileWatch{ 1068 ObjectMeta: metav1.ObjectMeta{Name: "frontend-fw"}, 1069 Spec: v1alpha1.FileWatchSpec{ 1070 WatchedPaths: []string{p}, 1071 }, 1072 Status: v1alpha1.FileWatchStatus{ 1073 MonitorStartTime: nowMicro, 1074 }, 1075 }) 1076 f.Create(&v1alpha1.DockerComposeService{ 1077 ObjectMeta: metav1.ObjectMeta{Name: "frontend-service"}, 1078 Status: v1alpha1.DockerComposeServiceStatus{ 1079 ContainerID: "main-id", 1080 ContainerState: &v1alpha1.DockerContainerState{ 1081 Status: dockercompose.ContainerStatusRunning, 1082 StartedAt: nowMicro, 1083 }, 1084 }, 1085 }) 1086 f.Create(&v1alpha1.ImageMap{ 1087 ObjectMeta: metav1.ObjectMeta{Name: "frontend-image-map"}, 1088 Status: v1alpha1.ImageMapStatus{ 1089 Image: "frontend-image:my-tag", 1090 ImageFromCluster: "frontend-image:my-tag", 1091 BuildStartTime: &nowMicro, 1092 }, 1093 }) 1094 f.Create(&v1alpha1.LiveUpdate{ 1095 ObjectMeta: metav1.ObjectMeta{ 1096 Name: "frontend-liveupdate", 1097 Annotations: map[string]string{ 1098 v1alpha1.AnnotationManifest: "frontend", 1099 liveupdate.AnnotationUpdateMode: "auto", 1100 }, 1101 }, 1102 Spec: v1alpha1.LiveUpdateSpec{ 1103 BasePath: p, 1104 Sources: []v1alpha1.LiveUpdateSource{{ 1105 FileWatch: "frontend-fw", 1106 ImageMap: "frontend-image-map", 1107 }}, 1108 Selector: v1alpha1.LiveUpdateSelector{ 1109 DockerCompose: &v1alpha1.LiveUpdateDockerComposeSelector{ 1110 Service: "frontend-service", 1111 }, 1112 }, 1113 Syncs: []v1alpha1.LiveUpdateSync{ 1114 {LocalPath: ".", ContainerPath: "/app"}, 1115 }, 1116 StopPaths: []string{"stop.txt"}, 1117 }, 1118 }) 1119 f.Create(&v1alpha1.ConfigMap{ 1120 ObjectMeta: metav1.ObjectMeta{ 1121 Name: configmap.TriggerQueueName, 1122 }, 1123 }) 1124 } 1125 1126 func (f *fixture) assertSteadyState(lu *v1alpha1.LiveUpdate) { 1127 startCalls := len(f.cu.Calls) 1128 1129 f.T().Helper() 1130 f.MustReconcile(types.NamespacedName{Name: lu.Name}) 1131 var lu2 v1alpha1.LiveUpdate 1132 f.MustGet(types.NamespacedName{Name: lu.Name}, &lu2) 1133 assert.Equal(f.T(), lu.ResourceVersion, lu2.ResourceVersion) 1134 1135 assert.Equal(f.T(), startCalls, len(f.cu.Calls)) 1136 } 1137 1138 func (f *fixture) kdUpdateStatus(name string, status v1alpha1.KubernetesDiscoveryStatus) { 1139 var kd v1alpha1.KubernetesDiscovery 1140 f.MustGet(types.NamespacedName{Name: name}, &kd) 1141 update := kd.DeepCopy() 1142 update.Status = status 1143 f.UpdateStatus(update) 1144 }