github.com/tilt-dev/tilt@v0.33.15-0.20240515162809-0a22ed45d8a0/internal/controllers/core/cmd/controller_test.go (about) 1 package cmd 2 3 import ( 4 "context" 5 "fmt" 6 "io" 7 "strconv" 8 "strings" 9 "testing" 10 "time" 11 12 "github.com/jonboulle/clockwork" 13 "github.com/stretchr/testify/assert" 14 "github.com/stretchr/testify/require" 15 apierrors "k8s.io/apimachinery/pkg/api/errors" 16 metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 17 "k8s.io/apimachinery/pkg/types" 18 "k8s.io/utils/pointer" 19 ctrl "sigs.k8s.io/controller-runtime" 20 "sigs.k8s.io/controller-runtime/pkg/reconcile" 21 22 "github.com/tilt-dev/tilt/internal/controllers/fake" 23 "github.com/tilt-dev/tilt/internal/engine/local" 24 "github.com/tilt-dev/tilt/internal/store" 25 "github.com/tilt-dev/tilt/internal/testutils/configmap" 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/model" 29 ) 30 31 var timeout = time.Second 32 var interval = 5 * time.Millisecond 33 34 func TestNoop(t *testing.T) { 35 f := newFixture(t) 36 37 f.step() 38 f.assertCmdCount(0) 39 } 40 41 func TestUpdate(t *testing.T) { 42 f := newFixture(t) 43 44 t1 := time.Unix(1, 0) 45 f.resource("foo", "true", ".", t1) 46 f.step() 47 f.assertCmdMatches("foo-serve-1", func(cmd *Cmd) bool { 48 return cmd.Status.Running != nil 49 }) 50 51 t2 := time.Unix(2, 0) 52 f.resource("foo", "false", ".", t2) 53 f.step() 54 f.assertCmdDeleted("foo-serve-1") 55 56 f.step() 57 f.assertCmdMatches("foo-serve-2", func(cmd *Cmd) bool { 58 return cmd.Status.Running != nil 59 }) 60 61 f.fe.RequireNoKnownProcess(t, "true") 62 f.assertLogMessage("foo", "Starting cmd false") 63 f.assertLogMessage("foo", "cmd true canceled") 64 f.assertCmdCount(1) 65 } 66 67 func TestUpdateWithCurrentBuild(t *testing.T) { 68 f := newFixture(t) 69 70 t1 := time.Unix(1, 0) 71 f.resource("foo", "true", ".", t1) 72 f.step() 73 f.assertCmdMatches("foo-serve-1", func(cmd *Cmd) bool { 74 return cmd.Status.Running != nil 75 }) 76 77 f.st.WithState(func(s *store.EngineState) { 78 c := model.ToHostCmd("false") 79 localTarget := model.NewLocalTarget(model.TargetName("foo"), c, c, nil) 80 s.ManifestTargets["foo"].Manifest.DeployTarget = localTarget 81 s.ManifestTargets["foo"].State.CurrentBuilds["buildcontrol"] = model.BuildRecord{StartTime: f.clock.Now()} 82 }) 83 84 f.step() 85 86 assert.Never(t, func() bool { 87 return f.st.Cmd("foo-serve-2") != nil 88 }, 20*time.Millisecond, 5*time.Millisecond) 89 90 f.st.WithState(func(s *store.EngineState) { 91 delete(s.ManifestTargets["foo"].State.CurrentBuilds, "buildcontrol") 92 }) 93 94 f.step() 95 f.assertCmdDeleted("foo-serve-1") 96 } 97 98 func TestServe(t *testing.T) { 99 f := newFixture(t) 100 101 t1 := time.Unix(1, 0) 102 f.resource("foo", "sleep 60", "testdir", t1) 103 f.step() 104 f.assertCmdMatches("foo-serve-1", func(cmd *Cmd) bool { 105 return cmd.Status.Running != nil && cmd.Status.Ready 106 }) 107 108 require.Equal(t, "testdir", f.fe.processes["sleep 60"].workdir) 109 110 f.assertLogMessage("foo", "Starting cmd sleep 60") 111 } 112 113 func TestServeReadinessProbe(t *testing.T) { 114 f := newFixture(t) 115 116 t1 := time.Unix(1, 0) 117 118 c := model.ToHostCmdInDir("sleep 60", "testdir") 119 localTarget := model.NewLocalTarget("foo", model.Cmd{}, c, nil) 120 localTarget.ReadinessProbe = &v1alpha1.Probe{ 121 TimeoutSeconds: 5, 122 Handler: v1alpha1.Handler{ 123 Exec: &v1alpha1.ExecAction{Command: []string{"sleep", "15"}}, 124 }, 125 } 126 127 f.resourceFromTarget("foo", localTarget, t1) 128 f.step() 129 f.assertCmdMatches("foo-serve-1", func(cmd *Cmd) bool { 130 return cmd.Status.Running != nil && cmd.Status.Ready 131 }) 132 f.assertLogMessage("foo", "[readiness probe: success] fake probe succeeded") 133 134 assert.Equal(t, "sleep", f.fpm.execName) 135 assert.Equal(t, []string{"15"}, f.fpm.execArgs) 136 assert.GreaterOrEqual(t, f.fpm.ProbeCount(), 1) 137 } 138 139 func TestServeReadinessProbeInvalidSpec(t *testing.T) { 140 f := newFixture(t) 141 142 t1 := time.Unix(1, 0) 143 144 c := model.ToHostCmdInDir("sleep 60", "testdir") 145 localTarget := model.NewLocalTarget("foo", model.Cmd{}, c, nil) 146 localTarget.ReadinessProbe = &v1alpha1.Probe{ 147 Handler: v1alpha1.Handler{HTTPGet: &v1alpha1.HTTPGetAction{ 148 // port > 65535 149 Port: 70000, 150 }}, 151 } 152 153 f.resourceFromTarget("foo", localTarget, t1) 154 f.step() 155 156 f.assertCmdMatches("foo-serve-1", func(cmd *Cmd) bool { 157 return cmd.Status.Terminated != nil && cmd.Status.Terminated.ExitCode == 1 158 }) 159 160 f.assertLogMessage("foo", "Invalid readiness probe: port number out of range: 70000") 161 assert.Equal(t, 0, f.fpm.ProbeCount()) 162 } 163 164 func TestFailure(t *testing.T) { 165 f := newFixture(t) 166 167 t1 := time.Unix(1, 0) 168 f.resource("foo", "true", ".", t1) 169 f.step() 170 f.assertCmdMatches("foo-serve-1", func(cmd *Cmd) bool { 171 return cmd.Status.Running != nil 172 }) 173 174 f.assertLogMessage("foo", "Starting cmd true") 175 176 err := f.fe.stop("true", 5) 177 require.NoError(t, err) 178 f.assertCmdMatches("foo-serve-1", func(cmd *Cmd) bool { 179 return cmd.Status.Terminated != nil && cmd.Status.Terminated.ExitCode == 5 180 }) 181 182 f.assertLogMessage("foo", "cmd true exited with code 5") 183 } 184 185 func TestUniqueSpanIDs(t *testing.T) { 186 f := newFixture(t) 187 188 t1 := time.Unix(1, 0) 189 f.resource("foo", "foo.sh", ".", t1) 190 f.resource("bar", "bar.sh", ".", t1) 191 f.step() 192 193 fooStart := f.waitForLogEventContaining("Starting cmd foo.sh") 194 barStart := f.waitForLogEventContaining("Starting cmd bar.sh") 195 require.NotEqual(t, fooStart.SpanID(), barStart.SpanID(), "different resources should have unique log span ids") 196 } 197 198 func TestTearDown(t *testing.T) { 199 f := newFixture(t) 200 201 t1 := time.Unix(1, 0) 202 f.resource("foo", "foo.sh", ".", t1) 203 f.resource("bar", "bar.sh", ".", t1) 204 f.step() 205 206 f.c.TearDown(f.Context()) 207 208 f.fe.RequireNoKnownProcess(t, "foo.sh") 209 f.fe.RequireNoKnownProcess(t, "bar.sh") 210 } 211 212 func TestRestartOnFileWatch(t *testing.T) { 213 f := newFixture(t) 214 215 f.resource("cmd", "true", ".", f.clock.Now()) 216 f.step() 217 218 firstStart := f.assertCmdMatches("cmd-serve-1", func(cmd *Cmd) bool { 219 return cmd.Status.Running != nil 220 }) 221 222 fw := &FileWatch{ 223 ObjectMeta: ObjectMeta{ 224 Name: "fw-1", 225 }, 226 Spec: FileWatchSpec{ 227 WatchedPaths: []string{t.TempDir()}, 228 }, 229 } 230 err := f.Client.Create(f.Context(), fw) 231 require.NoError(t, err) 232 233 f.clock.Advance(time.Second) 234 f.updateSpec("cmd-serve-1", func(spec *v1alpha1.CmdSpec) { 235 spec.RestartOn = &RestartOnSpec{ 236 FileWatches: []string{"fw-1"}, 237 } 238 }) 239 240 f.clock.Advance(time.Second) 241 f.triggerFileWatch("fw-1") 242 f.reconcileCmd("cmd-serve-1") 243 244 f.assertCmdMatches("cmd-serve-1", func(cmd *Cmd) bool { 245 running := cmd.Status.Running 246 return running != nil && running.StartedAt.Time.After(firstStart.Status.Running.StartedAt.Time) 247 }) 248 249 // Our fixture doesn't test reconcile.Request triage, 250 // so test it manually here. 251 assert.Equal(f.T(), 252 []reconcile.Request{ 253 reconcile.Request{NamespacedName: types.NamespacedName{Name: "cmd-serve-1"}}, 254 }, 255 f.c.indexer.Enqueue(context.Background(), fw)) 256 } 257 258 func TestRestartOnUIButton(t *testing.T) { 259 f := newFixture(t) 260 261 f.resource("cmd", "true", ".", f.clock.Now()) 262 f.step() 263 264 firstStart := f.assertCmdMatches("cmd-serve-1", func(cmd *Cmd) bool { 265 return cmd.Status.Running != nil 266 }) 267 268 f.clock.Advance(time.Second) 269 f.updateSpec("cmd-serve-1", func(spec *v1alpha1.CmdSpec) { 270 spec.RestartOn = &RestartOnSpec{ 271 UIButtons: []string{"b-1"}, 272 } 273 }) 274 275 b := &UIButton{ 276 ObjectMeta: ObjectMeta{ 277 Name: "b-1", 278 }, 279 Spec: UIButtonSpec{}, 280 } 281 err := f.Client.Create(f.Context(), b) 282 require.NoError(t, err) 283 284 f.clock.Advance(time.Second) 285 f.triggerButton("b-1", f.clock.Now()) 286 f.reconcileCmd("cmd-serve-1") 287 288 f.assertCmdMatches("cmd-serve-1", func(cmd *Cmd) bool { 289 running := cmd.Status.Running 290 return running != nil && running.StartedAt.Time.After(firstStart.Status.Running.StartedAt.Time) 291 }) 292 293 // Our fixture doesn't test reconcile.Request triage, 294 // so test it manually here. 295 assert.Equal(f.T(), 296 []reconcile.Request{ 297 reconcile.Request{NamespacedName: types.NamespacedName{Name: "cmd-serve-1"}}, 298 }, 299 f.c.indexer.Enqueue(context.Background(), b)) 300 } 301 302 func setupStartOnTest(t *testing.T, f *fixture) { 303 cmd := &Cmd{ 304 ObjectMeta: metav1.ObjectMeta{ 305 Name: "testcmd", 306 }, 307 Spec: v1alpha1.CmdSpec{ 308 Args: []string{"myserver"}, 309 StartOn: &StartOnSpec{ 310 UIButtons: []string{"b-1"}, 311 StartAfter: apis.NewTime(f.clock.Now()), 312 }, 313 }, 314 } 315 316 err := f.Client.Create(f.Context(), cmd) 317 require.NoError(t, err) 318 319 b := &UIButton{ 320 ObjectMeta: ObjectMeta{ 321 Name: "b-1", 322 }, 323 Spec: UIButtonSpec{}, 324 } 325 err = f.Client.Create(f.Context(), b) 326 require.NoError(t, err) 327 328 f.reconcileCmd("testcmd") 329 330 f.fe.RequireNoKnownProcess(t, "myserver") 331 } 332 333 func TestStartOnNoPreviousProcess(t *testing.T) { 334 f := newFixture(t) 335 336 startup := f.clock.Now() 337 338 setupStartOnTest(t, f) 339 340 f.clock.Advance(time.Second) 341 342 f.triggerButton("b-1", f.clock.Now()) 343 f.reconcileCmd("testcmd") 344 345 f.requireCmdMatchesInAPI("testcmd", func(cmd *Cmd) bool { 346 running := cmd.Status.Running 347 return running != nil && running.StartedAt.Time.After(startup) 348 }) 349 } 350 351 func TestStartOnDoesntRunOnCreation(t *testing.T) { 352 f := newFixture(t) 353 354 setupStartOnTest(t, f) 355 356 f.reconcileCmd("testcmd") 357 358 f.requireCmdMatchesInAPI("testcmd", func(cmd *Cmd) bool { 359 return cmd.Status.Waiting != nil && cmd.Status.Waiting.Reason == waitingOnStartOnReason 360 }) 361 362 f.fe.RequireNoKnownProcess(t, "myserver") 363 } 364 365 func TestStartOnStartAfter(t *testing.T) { 366 f := newFixture(t) 367 368 setupStartOnTest(t, f) 369 370 f.triggerButton("b-1", f.clock.Now().Add(-time.Minute)) 371 372 f.reconcileCmd("testcmd") 373 374 f.requireCmdMatchesInAPI("testcmd", func(cmd *Cmd) bool { 375 return cmd.Status.Waiting != nil && cmd.Status.Waiting.Reason == waitingOnStartOnReason 376 }) 377 378 f.fe.RequireNoKnownProcess(t, "myserver") 379 } 380 381 func TestStartOnRunningProcess(t *testing.T) { 382 f := newFixture(t) 383 384 setupStartOnTest(t, f) 385 386 f.clock.Advance(time.Second) 387 f.triggerButton("b-1", f.clock.Now()) 388 f.reconcileCmd("testcmd") 389 390 // wait for the initial process to start 391 f.requireCmdMatchesInAPI("testcmd", func(cmd *Cmd) bool { 392 return cmd.Status.Running != nil 393 }) 394 395 f.fe.mu.Lock() 396 st := f.fe.processes["myserver"].startTime 397 f.fe.mu.Unlock() 398 399 f.clock.Advance(time.Second) 400 401 secondClickTime := f.clock.Now() 402 f.triggerButton("b-1", secondClickTime) 403 f.reconcileCmd("testcmd") 404 405 f.requireCmdMatchesInAPI("testcmd", func(cmd *Cmd) bool { 406 running := cmd.Status.Running 407 return running != nil && !running.StartedAt.Time.Before(secondClickTime) 408 }) 409 410 // make sure it's not the same process 411 f.fe.mu.Lock() 412 p, ok := f.fe.processes["myserver"] 413 require.True(t, ok) 414 require.NotEqual(t, st, p.startTime) 415 f.fe.mu.Unlock() 416 } 417 418 func TestStartOnPreviousTerminatedProcess(t *testing.T) { 419 f := newFixture(t) 420 421 firstClickTime := f.clock.Now() 422 423 setupStartOnTest(t, f) 424 425 f.triggerButton("b-1", firstClickTime) 426 f.reconcileCmd("testcmd") 427 428 // wait for the initial process to start 429 f.requireCmdMatchesInAPI("testcmd", func(cmd *Cmd) bool { 430 return cmd.Status.Running != nil 431 }) 432 433 f.fe.mu.Lock() 434 st := f.fe.processes["myserver"].startTime 435 f.fe.mu.Unlock() 436 437 err := f.fe.stop("myserver", 1) 438 require.NoError(t, err) 439 440 // wait for the initial process to die 441 f.requireCmdMatchesInAPI("testcmd", func(cmd *Cmd) bool { 442 return cmd.Status.Terminated != nil 443 }) 444 445 f.clock.Advance(time.Second) 446 secondClickTime := f.clock.Now() 447 f.triggerButton("b-1", secondClickTime) 448 f.reconcileCmd("testcmd") 449 450 f.requireCmdMatchesInAPI("testcmd", func(cmd *Cmd) bool { 451 running := cmd.Status.Running 452 return running != nil && !running.StartedAt.Time.Before(secondClickTime) 453 }) 454 455 // make sure it's not the same process 456 f.fe.mu.Lock() 457 p, ok := f.fe.processes["myserver"] 458 require.True(t, ok) 459 require.NotEqual(t, st, p.startTime) 460 f.fe.mu.Unlock() 461 } 462 463 func TestDisposeOrphans(t *testing.T) { 464 f := newFixture(t) 465 466 t1 := time.Unix(1, 0) 467 f.resource("foo", "true", ".", t1) 468 f.step() 469 f.assertCmdMatches("foo-serve-1", func(cmd *Cmd) bool { 470 return cmd.Status.Running != nil 471 }) 472 473 f.st.WithState(func(es *store.EngineState) { 474 es.RemoveManifestTarget("foo") 475 }) 476 f.step() 477 f.assertCmdCount(0) 478 f.fe.RequireNoKnownProcess(t, "true") 479 } 480 481 func TestDisposeTerminatedWhenCmdChanges(t *testing.T) { 482 f := newFixture(t) 483 484 t1 := time.Unix(1, 0) 485 f.resource("foo", "true", ".", t1) 486 f.step() 487 488 f.assertCmdMatches("foo-serve-1", func(cmd *Cmd) bool { 489 return cmd.Status.Running != nil 490 }) 491 492 err := f.fe.stop("true", 0) 493 require.NoError(t, err) 494 495 f.assertCmdMatches("foo-serve-1", func(cmd *Cmd) bool { 496 return cmd.Status.Terminated != nil 497 }) 498 499 f.resource("foo", "true", "subdir", t1) 500 f.step() 501 f.assertCmdMatches("foo-serve-2", func(cmd *Cmd) bool { 502 return cmd.Status.Running != nil 503 }) 504 f.assertCmdDeleted("foo-serve-1") 505 } 506 507 func TestDisableCmd(t *testing.T) { 508 f := newFixture(t) 509 510 cmd := &Cmd{ 511 ObjectMeta: metav1.ObjectMeta{ 512 Name: "cmd-1", 513 }, 514 Spec: v1alpha1.CmdSpec{ 515 Args: []string{"sh", "-c", "sleep 10000"}, 516 DisableSource: &v1alpha1.DisableSource{ 517 ConfigMap: &v1alpha1.ConfigMapDisableSource{ 518 Name: "disable-cmd-1", 519 Key: "isDisabled", 520 }, 521 }, 522 }, 523 } 524 err := f.Client.Create(f.Context(), cmd) 525 require.NoError(t, err) 526 527 f.setDisabled(cmd.Name, false) 528 529 f.requireCmdMatchesInAPI(cmd.Name, func(cmd *Cmd) bool { 530 return cmd.Status.Running != nil && 531 cmd.Status.DisableStatus != nil && 532 cmd.Status.DisableStatus.State == v1alpha1.DisableStateEnabled 533 }) 534 535 f.setDisabled(cmd.Name, true) 536 537 f.requireCmdMatchesInAPI(cmd.Name, func(cmd *Cmd) bool { 538 return cmd.Status.Terminated != nil && 539 cmd.Status.DisableStatus != nil && 540 cmd.Status.DisableStatus.State == v1alpha1.DisableStateDisabled 541 }) 542 543 f.setDisabled(cmd.Name, false) 544 545 f.requireCmdMatchesInAPI(cmd.Name, func(cmd *Cmd) bool { 546 return cmd.Status.Running != nil && 547 cmd.Status.DisableStatus != nil && 548 cmd.Status.DisableStatus.State == v1alpha1.DisableStateEnabled 549 }) 550 } 551 552 func TestReenable(t *testing.T) { 553 f := newFixture(t) 554 555 cmd := &Cmd{ 556 ObjectMeta: metav1.ObjectMeta{ 557 Name: "cmd-1", 558 }, 559 Spec: v1alpha1.CmdSpec{ 560 Args: []string{"sh", "-c", "sleep 10000"}, 561 DisableSource: &v1alpha1.DisableSource{ 562 ConfigMap: &v1alpha1.ConfigMapDisableSource{ 563 Name: "disable-cmd-1", 564 Key: "isDisabled", 565 }, 566 }, 567 }, 568 } 569 err := f.Client.Create(f.Context(), cmd) 570 require.NoError(t, err) 571 572 f.setDisabled(cmd.Name, true) 573 574 f.requireCmdMatchesInAPI(cmd.Name, func(cmd *Cmd) bool { 575 return cmd.Status.Running == nil && 576 cmd.Status.DisableStatus != nil && 577 cmd.Status.DisableStatus.State == v1alpha1.DisableStateDisabled 578 }) 579 580 f.setDisabled(cmd.Name, false) 581 582 f.requireCmdMatchesInAPI(cmd.Name, func(cmd *Cmd) bool { 583 return cmd.Status.Running != nil && 584 cmd.Status.DisableStatus != nil && 585 cmd.Status.DisableStatus.State == v1alpha1.DisableStateEnabled 586 }) 587 } 588 589 func TestDisableServeCmd(t *testing.T) { 590 f := newFixture(t) 591 592 ds := v1alpha1.DisableSource{ConfigMap: &v1alpha1.ConfigMapDisableSource{Name: "disable-foo", Key: "isDisabled"}} 593 t1 := time.Unix(1, 0) 594 localTarget := model.NewLocalTarget("foo", model.Cmd{}, model.ToHostCmd("."), nil) 595 localTarget.ServeCmdDisableSource = &ds 596 err := configmap.UpsertDisableConfigMap(f.Context(), f.Client, ds.ConfigMap.Name, ds.ConfigMap.Key, false) 597 require.NoError(t, err) 598 599 f.resourceFromTarget("foo", localTarget, t1) 600 601 f.step() 602 f.requireCmdMatchesInAPI("foo-serve-1", func(cmd *Cmd) bool { 603 return cmd != nil && cmd.Status.Running != nil 604 }) 605 606 err = configmap.UpsertDisableConfigMap(f.Context(), f.Client, ds.ConfigMap.Name, ds.ConfigMap.Key, true) 607 require.NoError(t, err) 608 609 f.step() 610 f.assertCmdCount(0) 611 } 612 613 func TestEnableServeCmd(t *testing.T) { 614 f := newFixture(t) 615 616 ds := v1alpha1.DisableSource{ConfigMap: &v1alpha1.ConfigMapDisableSource{Name: "disable-foo", Key: "isDisabled"}} 617 err := configmap.UpsertDisableConfigMap(f.Context(), f.Client, ds.ConfigMap.Name, ds.ConfigMap.Key, true) 618 require.NoError(t, err) 619 620 t1 := time.Unix(1, 0) 621 localTarget := model.NewLocalTarget("foo", model.Cmd{}, model.ToHostCmd("."), nil) 622 localTarget.ServeCmdDisableSource = &ds 623 f.resourceFromTarget("foo", localTarget, t1) 624 625 f.step() 626 f.assertCmdCount(0) 627 err = configmap.UpsertDisableConfigMap(f.Context(), f.Client, ds.ConfigMap.Name, ds.ConfigMap.Key, false) 628 require.NoError(t, err) 629 630 f.step() 631 f.requireCmdMatchesInAPI("foo-serve-1", func(cmd *Cmd) bool { 632 return cmd != nil && cmd.Status.Running != nil 633 }) 634 } 635 636 // Self-modifying Cmds are typically paired with a StartOn trigger, 637 // to simulate a "toggle" switch on the Cmd. 638 // 639 // See: 640 // https://github.com/tilt-dev/tilt-extensions/issues/202 641 func TestSelfModifyingCmd(t *testing.T) { 642 f := newFixture(t) 643 644 setupStartOnTest(t, f) 645 646 f.reconcileCmd("testcmd") 647 648 f.requireCmdMatchesInAPI("testcmd", func(cmd *Cmd) bool { 649 return cmd.Status.Waiting != nil && cmd.Status.Waiting.Reason == waitingOnStartOnReason 650 }) 651 652 f.clock.Advance(time.Second) 653 f.triggerButton("b-1", f.clock.Now()) 654 f.clock.Advance(time.Second) 655 f.reconcileCmd("testcmd") 656 657 f.requireCmdMatchesInAPI("testcmd", func(cmd *Cmd) bool { 658 return cmd.Status.Running != nil 659 }) 660 661 f.updateSpec("testcmd", func(spec *v1alpha1.CmdSpec) { 662 spec.Args = []string{"yourserver"} 663 }) 664 f.reconcileCmd("testcmd") 665 f.requireCmdMatchesInAPI("testcmd", func(cmd *Cmd) bool { 666 return cmd.Status.Waiting != nil && cmd.Status.Waiting.Reason == waitingOnStartOnReason 667 }) 668 669 f.fe.RequireNoKnownProcess(t, "myserver") 670 f.fe.RequireNoKnownProcess(t, "yourserver") 671 f.clock.Advance(time.Second) 672 f.triggerButton("b-1", f.clock.Now()) 673 f.reconcileCmd("testcmd") 674 675 f.requireCmdMatchesInAPI("testcmd", func(cmd *Cmd) bool { 676 return cmd.Status.Running != nil 677 }) 678 } 679 680 // Ensure that changes to the StartOn or RestartOn fields 681 // don't restart the command. 682 func TestDependencyChangesDoNotCauseRestart(t *testing.T) { 683 f := newFixture(t) 684 685 setupStartOnTest(t, f) 686 f.triggerButton("b-1", f.clock.Now()) 687 f.clock.Advance(time.Second) 688 f.reconcileCmd("testcmd") 689 690 firstStart := f.requireCmdMatchesInAPI("testcmd", func(cmd *Cmd) bool { 691 return cmd.Status.Running != nil 692 }) 693 694 err := f.Client.Create(f.Context(), &v1alpha1.UIButton{ObjectMeta: metav1.ObjectMeta{Name: "new-button"}}) 695 require.NoError(t, err) 696 697 err = f.Client.Create(f.Context(), &v1alpha1.FileWatch{ 698 ObjectMeta: metav1.ObjectMeta{Name: "new-filewatch"}, 699 Spec: FileWatchSpec{ 700 WatchedPaths: []string{t.TempDir()}, 701 }, 702 }) 703 require.NoError(t, err) 704 705 f.updateSpec("testcmd", func(spec *v1alpha1.CmdSpec) { 706 spec.StartOn = &v1alpha1.StartOnSpec{ 707 UIButtons: []string{"new-button"}, 708 } 709 spec.RestartOn = &v1alpha1.RestartOnSpec{ 710 FileWatches: []string{"new-filewatch"}, 711 } 712 }) 713 f.reconcileCmd("testcmd") 714 715 f.requireCmdMatchesInAPI("testcmd", func(cmd *Cmd) bool { 716 running := cmd.Status.Running 717 return running != nil && running.StartedAt.Time.Equal(firstStart.Status.Running.StartedAt.Time) 718 }) 719 } 720 721 func TestCmdUsesInputsFromButtonOnStart(t *testing.T) { 722 f := newFixture(t) 723 724 setupStartOnTest(t, f) 725 f.updateButton("b-1", func(button *v1alpha1.UIButton) { 726 button.Spec.Inputs = []v1alpha1.UIInputSpec{ 727 {Name: "foo", Text: &v1alpha1.UITextInputSpec{}}, 728 {Name: "baz", Text: &v1alpha1.UITextInputSpec{}}, 729 } 730 button.Status.Inputs = []v1alpha1.UIInputStatus{ 731 { 732 Name: "foo", 733 Text: &v1alpha1.UITextInputStatus{Value: "bar"}, 734 }, 735 { 736 Name: "baz", 737 Text: &v1alpha1.UITextInputStatus{Value: "wait what comes next"}, 738 }, 739 } 740 }) 741 f.triggerButton("b-1", f.clock.Now()) 742 f.reconcileCmd("testcmd") 743 744 actualEnv := f.fe.processes["myserver"].env 745 expectedEnv := []string{"foo=bar", "baz=wait what comes next"} 746 require.Equal(t, expectedEnv, actualEnv) 747 } 748 749 func TestBoolInput(t *testing.T) { 750 for _, tc := range []struct { 751 name string 752 input v1alpha1.UIBoolInputSpec 753 value bool 754 expectedValue string 755 }{ 756 {"true, default", v1alpha1.UIBoolInputSpec{}, true, "true"}, 757 {"true, custom", v1alpha1.UIBoolInputSpec{TrueString: pointer.String("custom value")}, true, "custom value"}, 758 {"false, default", v1alpha1.UIBoolInputSpec{}, false, "false"}, 759 {"false, custom", v1alpha1.UIBoolInputSpec{FalseString: pointer.String("ooh la la")}, false, "ooh la la"}, 760 {"false, empty", v1alpha1.UIBoolInputSpec{FalseString: pointer.String("")}, false, ""}, 761 } { 762 t.Run(tc.name, func(t *testing.T) { 763 f := newFixture(t) 764 765 setupStartOnTest(t, f) 766 f.updateButton("b-1", func(button *v1alpha1.UIButton) { 767 spec := v1alpha1.UIInputSpec{Name: "dry_run", Bool: &tc.input} 768 button.Spec.Inputs = append(button.Spec.Inputs, spec) 769 status := v1alpha1.UIInputStatus{Name: "dry_run", Bool: &v1alpha1.UIBoolInputStatus{Value: tc.value}} 770 button.Status.Inputs = append(button.Status.Inputs, status) 771 }) 772 f.triggerButton("b-1", f.clock.Now()) 773 f.reconcileCmd("testcmd") 774 775 actualEnv := f.fe.processes["myserver"].env 776 expectedEnv := []string{fmt.Sprintf("dry_run=%s", tc.expectedValue)} 777 require.Equal(t, expectedEnv, actualEnv) 778 }) 779 } 780 } 781 782 func TestHiddenInput(t *testing.T) { 783 f := newFixture(t) 784 785 val := "afds" 786 787 setupStartOnTest(t, f) 788 f.updateButton("b-1", func(button *v1alpha1.UIButton) { 789 spec := v1alpha1.UIInputSpec{Name: "foo", Hidden: &v1alpha1.UIHiddenInputSpec{Value: val}} 790 button.Spec.Inputs = append(button.Spec.Inputs, spec) 791 status := v1alpha1.UIInputStatus{Name: "foo", Hidden: &v1alpha1.UIHiddenInputStatus{Value: val}} 792 button.Status.Inputs = append(button.Status.Inputs, status) 793 }) 794 f.triggerButton("b-1", f.clock.Now()) 795 f.reconcileCmd("testcmd") 796 797 actualEnv := f.fe.processes["myserver"].env 798 expectedEnv := []string{fmt.Sprintf("foo=%s", val)} 799 require.Equal(t, expectedEnv, actualEnv) 800 } 801 802 func TestChoiceInput(t *testing.T) { 803 for _, tc := range []struct { 804 name string 805 input v1alpha1.UIChoiceInputSpec 806 value string 807 expectedValue string 808 }{ 809 {"empty value", v1alpha1.UIChoiceInputSpec{Choices: []string{"choice1", "choice2"}}, "", "choice1"}, 810 {"invalid value", v1alpha1.UIChoiceInputSpec{Choices: []string{"choice1", "choice2"}}, "not in Choices", "choice1"}, 811 {"selected choice1", v1alpha1.UIChoiceInputSpec{Choices: []string{"choice1", "choice2"}}, "choice1", "choice1"}, 812 {"selected choice2", v1alpha1.UIChoiceInputSpec{Choices: []string{"choice1", "choice2"}}, "choice2", "choice2"}, 813 } { 814 t.Run(tc.name, func(t *testing.T) { 815 f := newFixture(t) 816 817 setupStartOnTest(t, f) 818 f.updateButton("b-1", func(button *v1alpha1.UIButton) { 819 spec := v1alpha1.UIInputSpec{Name: "dry_run", Choice: &tc.input} 820 button.Spec.Inputs = append(button.Spec.Inputs, spec) 821 status := v1alpha1.UIInputStatus{Name: "dry_run", Choice: &v1alpha1.UIChoiceInputStatus{Value: tc.value}} 822 button.Status.Inputs = append(button.Status.Inputs, status) 823 }) 824 f.triggerButton("b-1", f.clock.Now()) 825 f.reconcileCmd("testcmd") 826 827 actualEnv := f.fe.processes["myserver"].env 828 expectedEnv := []string{fmt.Sprintf("dry_run=%s", tc.expectedValue)} 829 require.Equal(t, expectedEnv, actualEnv) 830 }) 831 } 832 } 833 834 func TestCmdOnlyUsesButtonThatStartedIt(t *testing.T) { 835 f := newFixture(t) 836 837 setupStartOnTest(t, f) 838 f.updateButton("b-1", func(button *v1alpha1.UIButton) { 839 inputs := []v1alpha1.UIInputStatus{ 840 { 841 Name: "foo", 842 Text: &v1alpha1.UITextInputStatus{Value: "bar"}, 843 }, 844 { 845 Name: "baz", 846 Text: &v1alpha1.UITextInputStatus{Value: "wait what comes next"}, 847 }, 848 } 849 button.Status.Inputs = append(button.Status.Inputs, inputs...) 850 }) 851 852 b := &UIButton{ 853 ObjectMeta: ObjectMeta{ 854 Name: "b-2", 855 }, 856 Spec: UIButtonSpec{}, 857 } 858 err := f.Client.Create(f.Context(), b) 859 require.NoError(t, err) 860 f.updateSpec("testcmd", func(spec *v1alpha1.CmdSpec) { 861 spec.StartOn.UIButtons = append(spec.StartOn.UIButtons, "b-2") 862 }) 863 f.triggerButton("b-2", f.clock.Now()) 864 f.reconcileCmd("testcmd") 865 866 actualEnv := f.fe.processes["myserver"].env 867 // b-1's env gets ignored since it was triggered by b-2 868 expectedEnv := []string{} 869 require.Equal(t, expectedEnv, actualEnv) 870 } 871 872 type testStore struct { 873 *store.TestingStore 874 out io.Writer 875 summary store.ChangeSummary 876 } 877 878 func NewTestingStore(out io.Writer) *testStore { 879 return &testStore{ 880 TestingStore: store.NewTestingStore(), 881 out: out, 882 } 883 } 884 885 func (s *testStore) Cmd(name string) *Cmd { 886 st := s.RLockState() 887 defer s.RUnlockState() 888 return st.Cmds[name] 889 } 890 891 func (s *testStore) CmdCount() int { 892 st := s.RLockState() 893 defer s.RUnlockState() 894 count := 0 895 for _, cmd := range st.Cmds { 896 if cmd.DeletionTimestamp == nil { 897 count++ 898 } 899 } 900 return count 901 } 902 903 func (s *testStore) Dispatch(action store.Action) { 904 s.TestingStore.Dispatch(action) 905 906 st := s.LockMutableStateForTesting() 907 defer s.UnlockMutableState() 908 909 switch action := action.(type) { 910 case store.ErrorAction: 911 panic(fmt.Sprintf("no error action allowed: %s", action.Error)) 912 913 case store.LogAction: 914 _, _ = s.out.Write(action.Message()) 915 916 case local.CmdCreateAction: 917 local.HandleCmdCreateAction(st, action) 918 action.Summarize(&s.summary) 919 920 case local.CmdUpdateStatusAction: 921 local.HandleCmdUpdateStatusAction(st, action) 922 923 case local.CmdDeleteAction: 924 local.HandleCmdDeleteAction(st, action) 925 action.Summarize(&s.summary) 926 } 927 } 928 929 type fixture struct { 930 *fake.ControllerFixture 931 st *testStore 932 fe *FakeExecer 933 fpm *FakeProberManager 934 sc *local.ServerController 935 c *Controller 936 clock clockwork.FakeClock 937 } 938 939 func newFixture(t *testing.T) *fixture { 940 f := fake.NewControllerFixtureBuilder(t) 941 st := NewTestingStore(f.OutWriter()) 942 943 fe := NewFakeExecer() 944 fpm := NewFakeProberManager() 945 sc := local.NewServerController(f.Client) 946 clock := clockwork.NewFakeClock() 947 c := NewController(f.Context(), fe, fpm, f.Client, st, clock, v1alpha1.NewScheme()) 948 949 return &fixture{ 950 ControllerFixture: f.WithRequeuer(c.requeuer).Build(c), 951 st: st, 952 fe: fe, 953 fpm: fpm, 954 sc: sc, 955 c: c, 956 clock: clock, 957 } 958 } 959 960 func (f *fixture) triggerFileWatch(name string) { 961 fw := &FileWatch{} 962 err := f.Client.Get(f.Context(), types.NamespacedName{Name: name}, fw) 963 require.NoError(f.T(), err) 964 965 fw.Status.LastEventTime = apis.NewMicroTime(f.clock.Now()) 966 err = f.Client.Status().Update(f.Context(), fw) 967 require.NoError(f.T(), err) 968 } 969 970 func (f *fixture) triggerButton(name string, ts time.Time) { 971 f.updateButton(name, func(b *v1alpha1.UIButton) { 972 b.Status.LastClickedAt = apis.NewMicroTime(ts) 973 }) 974 } 975 976 func (f *fixture) reconcileCmd(name string) { 977 _, err := f.c.Reconcile(f.Context(), ctrl.Request{NamespacedName: types.NamespacedName{Name: name}}) 978 require.NoError(f.T(), err) 979 } 980 981 func (f *fixture) updateSpec(name string, update func(spec *v1alpha1.CmdSpec)) { 982 cmd := &Cmd{} 983 err := f.Client.Get(f.Context(), types.NamespacedName{Name: name}, cmd) 984 require.NoError(f.T(), err) 985 986 update(&(cmd.Spec)) 987 err = f.Client.Update(f.Context(), cmd) 988 require.NoError(f.T(), err) 989 } 990 991 func (f *fixture) updateButton(name string, update func(button *v1alpha1.UIButton)) { 992 button := &UIButton{} 993 err := f.Client.Get(f.Context(), types.NamespacedName{Name: name}, button) 994 require.NoError(f.T(), err) 995 996 update(button) 997 998 copy := button.DeepCopy() 999 err = f.Client.Update(f.Context(), button) 1000 require.NoError(f.T(), err) 1001 1002 button.Status = copy.Status 1003 err = f.Client.Status().Update(f.Context(), button) 1004 require.NoError(f.T(), err) 1005 } 1006 1007 // checks `cmdName`'s DisableSource and makes sure it's configured to be disabled or enabled per `isDisabled` 1008 func (f *fixture) setDisabled(cmdName string, isDisabled bool) { 1009 cmd := &Cmd{} 1010 err := f.Client.Get(f.Context(), types.NamespacedName{Name: cmdName}, cmd) 1011 require.NoError(f.T(), err) 1012 1013 require.NotNil(f.T(), cmd.Spec.DisableSource) 1014 require.NotNil(f.T(), cmd.Spec.DisableSource.ConfigMap) 1015 1016 configMap := &ConfigMap{} 1017 err = f.Client.Get(f.Context(), types.NamespacedName{Name: cmd.Spec.DisableSource.ConfigMap.Name}, configMap) 1018 if apierrors.IsNotFound(err) { 1019 configMap.ObjectMeta.Name = cmd.Spec.DisableSource.ConfigMap.Name 1020 configMap.Data = map[string]string{cmd.Spec.DisableSource.ConfigMap.Key: strconv.FormatBool(isDisabled)} 1021 err = f.Client.Create(f.Context(), configMap) 1022 require.NoError(f.T(), err) 1023 } else { 1024 require.Nil(f.T(), err) 1025 configMap.Data[cmd.Spec.DisableSource.ConfigMap.Key] = strconv.FormatBool(isDisabled) 1026 err = f.Client.Update(f.Context(), configMap) 1027 require.NoError(f.T(), err) 1028 } 1029 1030 f.reconcileCmd(cmdName) 1031 1032 var expectedDisableState v1alpha1.DisableState 1033 if isDisabled { 1034 expectedDisableState = v1alpha1.DisableStateDisabled 1035 } else { 1036 expectedDisableState = v1alpha1.DisableStateEnabled 1037 } 1038 1039 // block until the change has been processed 1040 f.requireCmdMatchesInAPI(cmdName, func(cmd *Cmd) bool { 1041 return cmd.Status.DisableStatus != nil && 1042 cmd.Status.DisableStatus.State == expectedDisableState 1043 }) 1044 } 1045 1046 func (f *fixture) resource(name string, cmd string, workdir string, lastDeploy time.Time) { 1047 c := model.ToHostCmd(cmd) 1048 c.Dir = workdir 1049 localTarget := model.NewLocalTarget(model.TargetName(name), model.Cmd{}, c, nil) 1050 f.resourceFromTarget(name, localTarget, lastDeploy) 1051 } 1052 1053 func (f *fixture) resourceFromTarget(name string, target model.TargetSpec, lastDeploy time.Time) { 1054 n := model.ManifestName(name) 1055 m := model.Manifest{ 1056 Name: n, 1057 }.WithDeployTarget(target) 1058 1059 st := f.st.LockMutableStateForTesting() 1060 defer f.st.UnlockMutableState() 1061 1062 state := store.NewManifestState(m) 1063 state.LastSuccessfulDeployTime = lastDeploy 1064 state.AddCompletedBuild(model.BuildRecord{ 1065 StartTime: lastDeploy, 1066 FinishTime: lastDeploy, 1067 }) 1068 st.UpsertManifestTarget(&store.ManifestTarget{ 1069 Manifest: m, 1070 State: state, 1071 }) 1072 } 1073 1074 func (f *fixture) step() { 1075 f.st.summary = store.ChangeSummary{} 1076 _ = f.sc.OnChange(f.Context(), f.st, store.LegacyChangeSummary()) 1077 for name := range f.st.summary.CmdSpecs.Changes { 1078 _, err := f.c.Reconcile(f.Context(), ctrl.Request{NamespacedName: name}) 1079 require.NoError(f.T(), err) 1080 } 1081 } 1082 1083 func (f *fixture) assertLogMessage(name string, messages ...string) { 1084 for _, m := range messages { 1085 assert.Eventually(f.T(), func() bool { 1086 return strings.Contains(f.Stdout(), m) 1087 }, timeout, interval) 1088 } 1089 } 1090 1091 func (f *fixture) waitForLogEventContaining(message string) store.LogAction { 1092 ctx, cancel := context.WithTimeout(f.Context(), time.Second) 1093 defer cancel() 1094 1095 for { 1096 actions := f.st.Actions() 1097 for _, action := range actions { 1098 le, ok := action.(store.LogAction) 1099 if ok && strings.Contains(string(le.Message()), message) { 1100 return le 1101 } 1102 } 1103 select { 1104 case <-ctx.Done(): 1105 f.T().Fatalf("timed out waiting for log event w/ message %q. seen actions: %v", message, actions) 1106 case <-time.After(20 * time.Millisecond): 1107 } 1108 } 1109 } 1110 1111 func (f *fixture) assertCmdMatches(name string, matcher func(cmd *Cmd) bool) *Cmd { 1112 f.T().Helper() 1113 assert.Eventually(f.T(), func() bool { 1114 cmd := f.st.Cmd(name) 1115 if cmd == nil { 1116 return false 1117 } 1118 return matcher(cmd) 1119 }, timeout, interval) 1120 1121 return f.requireCmdMatchesInAPI(name, matcher) 1122 } 1123 1124 func (f *fixture) requireCmdMatchesInAPI(name string, matcher func(cmd *Cmd) bool) *Cmd { 1125 f.T().Helper() 1126 var cmd Cmd 1127 1128 require.Eventually(f.T(), func() bool { 1129 err := f.Client.Get(f.Context(), types.NamespacedName{Name: name}, &cmd) 1130 require.NoError(f.T(), err) 1131 return matcher(&cmd) 1132 }, timeout, interval) 1133 1134 return &cmd 1135 } 1136 1137 func (f *fixture) assertCmdDeleted(name string) { 1138 assert.Eventually(f.T(), func() bool { 1139 cmd := f.st.Cmd(name) 1140 return cmd == nil || cmd.DeletionTimestamp != nil 1141 }, timeout, interval) 1142 1143 var cmd Cmd 1144 err := f.Client.Get(f.Context(), types.NamespacedName{Name: name}, &cmd) 1145 assert.Error(f.T(), err) 1146 assert.True(f.T(), apierrors.IsNotFound(err)) 1147 } 1148 1149 func (f *fixture) assertCmdCount(count int) { 1150 assert.Equal(f.T(), count, f.st.CmdCount()) 1151 1152 var list CmdList 1153 err := f.Client.List(f.Context(), &list) 1154 require.NoError(f.T(), err) 1155 assert.Equal(f.T(), count, len(list.Items)) 1156 }