github.com/tilt-dev/tilt@v0.33.15-0.20240515162809-0a22ed45d8a0/internal/controllers/core/tiltfile/reconciler.go (about) 1 package tiltfile 2 3 import ( 4 "context" 5 "fmt" 6 "sync" 7 "time" 8 9 dockertypes "github.com/docker/docker/api/types" 10 "github.com/pkg/errors" 11 apierrors "k8s.io/apimachinery/pkg/api/errors" 12 metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 13 "k8s.io/apimachinery/pkg/runtime" 14 "k8s.io/apimachinery/pkg/types" 15 ctrl "sigs.k8s.io/controller-runtime" 16 "sigs.k8s.io/controller-runtime/pkg/builder" 17 "sigs.k8s.io/controller-runtime/pkg/client" 18 ctrlclient "sigs.k8s.io/controller-runtime/pkg/client" 19 "sigs.k8s.io/controller-runtime/pkg/handler" 20 "sigs.k8s.io/controller-runtime/pkg/reconcile" 21 22 "github.com/tilt-dev/tilt/internal/controllers/apicmp" 23 "github.com/tilt-dev/tilt/internal/controllers/apis/configmap" 24 "github.com/tilt-dev/tilt/internal/controllers/apis/trigger" 25 "github.com/tilt-dev/tilt/internal/controllers/indexer" 26 "github.com/tilt-dev/tilt/internal/docker" 27 "github.com/tilt-dev/tilt/internal/k8s" 28 "github.com/tilt-dev/tilt/internal/sliceutils" 29 "github.com/tilt-dev/tilt/internal/store" 30 "github.com/tilt-dev/tilt/internal/store/buildcontrols" 31 "github.com/tilt-dev/tilt/internal/store/tiltfiles" 32 "github.com/tilt-dev/tilt/internal/tiltfile" 33 "github.com/tilt-dev/tilt/internal/timecmp" 34 "github.com/tilt-dev/tilt/pkg/apis" 35 "github.com/tilt-dev/tilt/pkg/apis/core/v1alpha1" 36 "github.com/tilt-dev/tilt/pkg/logger" 37 "github.com/tilt-dev/tilt/pkg/model" 38 ) 39 40 type Reconciler struct { 41 mu sync.Mutex 42 st store.RStore 43 tfl tiltfile.TiltfileLoader 44 dockerClient docker.Client 45 ctrlClient ctrlclient.Client 46 k8sContextOverride k8s.KubeContextOverride 47 k8sNamespaceOverride k8s.NamespaceOverride 48 indexer *indexer.Indexer 49 requeuer *indexer.Requeuer 50 engineMode store.EngineMode 51 loadCount int // used to differentiate spans 52 ciTimeoutFlag model.CITimeoutFlag 53 54 runs map[types.NamespacedName]*runStatus 55 56 // dockerConnectMetricReporter ensures we only report a single Docker connect status 57 // event per `tilt up`. Currently, a client is initialized on start (via wire/DI) 58 // and if there's an error, an exploding client is created; we'll never attempt 59 // to make a new one after that, so reporting on subsequent Tiltfile loads is 60 // not useful, as there's no way its status can change currently (a restart of 61 // Tilt is required). 62 dockerConnectMetricReporter sync.Once 63 } 64 65 func (r *Reconciler) CreateBuilder(mgr ctrl.Manager) (*builder.Builder, error) { 66 b := ctrl.NewControllerManagedBy(mgr). 67 For(&v1alpha1.Tiltfile{}). 68 Watches(&v1alpha1.ConfigMap{}, 69 handler.EnqueueRequestsFromMapFunc(r.enqueueTriggerQueue)). 70 WatchesRawSource(r.requeuer) 71 72 trigger.SetupControllerRestartOn(b, r.indexer, func(obj ctrlclient.Object) *v1alpha1.RestartOnSpec { 73 return obj.(*v1alpha1.Tiltfile).Spec.RestartOn 74 }) 75 trigger.SetupControllerStopOn(b, r.indexer, func(obj ctrlclient.Object) *v1alpha1.StopOnSpec { 76 return obj.(*v1alpha1.Tiltfile).Spec.StopOn 77 }) 78 79 return b, nil 80 } 81 82 func NewReconciler( 83 st store.RStore, 84 tfl tiltfile.TiltfileLoader, 85 dockerClient docker.Client, 86 ctrlClient ctrlclient.Client, 87 scheme *runtime.Scheme, 88 engineMode store.EngineMode, 89 k8sContextOverride k8s.KubeContextOverride, 90 k8sNamespaceOverride k8s.NamespaceOverride, 91 ciTimeoutFlag model.CITimeoutFlag, 92 ) *Reconciler { 93 return &Reconciler{ 94 st: st, 95 tfl: tfl, 96 dockerClient: dockerClient, 97 ctrlClient: ctrlClient, 98 indexer: indexer.NewIndexer(scheme, indexTiltfile), 99 runs: make(map[types.NamespacedName]*runStatus), 100 requeuer: indexer.NewRequeuer(), 101 engineMode: engineMode, 102 k8sContextOverride: k8sContextOverride, 103 k8sNamespaceOverride: k8sNamespaceOverride, 104 ciTimeoutFlag: ciTimeoutFlag, 105 } 106 } 107 108 // Reconcile manages Tiltfile execution. 109 func (r *Reconciler) Reconcile(ctx context.Context, request reconcile.Request) (reconcile.Result, error) { 110 r.mu.Lock() 111 defer r.mu.Unlock() 112 nn := request.NamespacedName 113 114 var tf v1alpha1.Tiltfile 115 err := r.ctrlClient.Get(ctx, nn, &tf) 116 r.indexer.OnReconcile(nn, &tf) 117 if err != nil && !apierrors.IsNotFound(err) { 118 return ctrl.Result{}, err 119 } 120 121 if apierrors.IsNotFound(err) || !tf.ObjectMeta.DeletionTimestamp.IsZero() { 122 r.deleteExistingRun(nn) 123 124 // Delete owned objects 125 err := updateOwnedObjects(ctx, r.ctrlClient, nn, nil, nil, false, r.ciTimeoutFlag, r.engineMode, r.defaultK8sConnection()) 126 if err != nil { 127 return ctrl.Result{}, err 128 } 129 r.st.Dispatch(tiltfiles.NewTiltfileDeleteAction(nn.Name)) 130 return ctrl.Result{}, nil 131 } 132 133 // The apiserver is the source of truth, and will ensure the engine state is up to date. 134 r.st.Dispatch(tiltfiles.NewTiltfileUpsertAction(&tf)) 135 136 ctx = store.MustObjectLogHandler(ctx, r.st, &tf) 137 run := r.runs[nn] 138 if run == nil { 139 // Initialize the UISession and filewatch if this has never been initialized before. 140 err := updateOwnedObjects(ctx, r.ctrlClient, nn, &tf, nil, false, r.ciTimeoutFlag, r.engineMode, r.defaultK8sConnection()) 141 if err != nil { 142 return ctrl.Result{}, err 143 } 144 } 145 146 step := runStepNone 147 if run != nil { 148 step = run.step 149 ctx = run.entry.WithLogger(ctx, r.st) 150 } 151 152 if step == runStepRunning { 153 lastStopTime, _, err := trigger.LastStopEvent(ctx, r.ctrlClient, tf.Spec.StopOn) 154 if err != nil { 155 return ctrl.Result{}, err 156 } 157 if timecmp.AfterOrEqual(lastStopTime, run.startTime) { 158 run.cancel() 159 } 160 } 161 162 // If the tiltfile isn't being run, check to see if anything has triggered a run. 163 if step == runStepNone || step == runStepDone { 164 lastRestartEventTime, _, fws, err := trigger.LastRestartEvent(ctx, r.ctrlClient, tf.Spec.RestartOn) 165 if err != nil { 166 return ctrl.Result{}, err 167 } 168 queue, err := configmap.TriggerQueue(ctx, r.ctrlClient) 169 if err != nil { 170 return ctrl.Result{}, err 171 } 172 173 be := r.needsBuild(ctx, nn, &tf, run, fws, queue, lastRestartEventTime) 174 if be != nil { 175 r.startRunAsync(ctx, nn, &tf, be, run) 176 } 177 } 178 179 // If the tiltfile has been loaded, we may still need to copy all its outputs 180 // to the apiserver. 181 if step == runStepLoaded { 182 err := r.handleLoaded(ctx, nn, &tf, run.entry, run.tlr) 183 if err != nil { 184 return ctrl.Result{}, err 185 } 186 } 187 188 run = r.runs[nn] 189 if run != nil { 190 newStatus := run.TiltfileStatus() 191 if !apicmp.DeepEqual(newStatus, tf.Status) { 192 update := tf.DeepCopy() 193 update.Status = run.TiltfileStatus() 194 err := r.ctrlClient.Status().Update(ctx, update) 195 if err != nil { 196 return ctrl.Result{}, err 197 } 198 } 199 } 200 201 return ctrl.Result{}, nil 202 } 203 204 // Modeled after BuildController.needsBuild and NextBuildReason(). Check to see that: 205 // 1. There's currently no Tiltfile build running, 206 // 2. There are pending file changes, and 207 // 3. Those files have changed since the last Tiltfile build 208 // (so that we don't keep re-running a failed build) 209 // 4. OR the command-line args have changed since the last Tiltfile build 210 // 5. OR user has manually triggered a Tiltfile build 211 func (r *Reconciler) needsBuild( 212 _ context.Context, 213 nn types.NamespacedName, 214 tf *v1alpha1.Tiltfile, 215 run *runStatus, 216 fileWatches []*v1alpha1.FileWatch, 217 triggerQueue *v1alpha1.ConfigMap, 218 lastRestartEvent metav1.MicroTime, 219 ) *BuildEntry { 220 var reason model.BuildReason 221 filesChanged := []string{} 222 223 step := runStepNone 224 lastStartTime := time.Time{} 225 lastStartArgs := []string{} 226 if run != nil { 227 step = run.step 228 lastStartTime = run.startTime 229 lastStartArgs = run.startArgs 230 } 231 232 if step == runStepNone { 233 reason = reason.With(model.BuildReasonFlagInit) 234 } else { 235 filesChanged = trigger.FilesChanged(tf.Spec.RestartOn, fileWatches, lastStartTime) 236 if len(filesChanged) > 0 { 237 reason = reason.With(model.BuildReasonFlagChangedFiles) 238 } else if timecmp.After(lastRestartEvent, lastStartTime) { 239 reason = reason.With(model.BuildReasonFlagTriggerUnknown) 240 } 241 } 242 243 if !lastStartTime.IsZero() && !apicmp.DeepEqual(tf.Spec.Args, lastStartArgs) { 244 reason = reason.With(model.BuildReasonFlagTiltfileArgs) 245 } 246 247 if configmap.InTriggerQueue(triggerQueue, nn) { 248 reason = reason.With(configmap.TriggerQueueReason(triggerQueue, nn)) 249 } 250 251 if reason == model.BuildReasonNone { 252 return nil 253 } 254 255 state := r.st.RLockState() 256 defer r.st.RUnlockState() 257 258 r.loadCount++ 259 260 return &BuildEntry{ 261 Name: model.ManifestName(nn.Name), 262 FilesChanged: filesChanged, 263 BuildReason: reason, 264 Args: tf.Spec.Args, 265 TiltfilePath: tf.Spec.Path, 266 CheckpointAtExecStart: state.LogStore.Checkpoint(), 267 LoadCount: r.loadCount, 268 ArgsChanged: !sliceutils.StringSliceEquals(lastStartArgs, tf.Spec.Args), 269 } 270 } 271 272 // Start a tiltfile run asynchronously, returning immediately. 273 func (r *Reconciler) startRunAsync(ctx context.Context, nn types.NamespacedName, tf *v1alpha1.Tiltfile, entry *BuildEntry, prevRun *runStatus) { 274 ctx = entry.WithLogger(ctx, r.st) 275 ctx, cancel := context.WithCancel(ctx) 276 277 var prevResult *tiltfile.TiltfileLoadResult 278 if prevRun != nil { 279 prevResult = prevRun.tlr 280 } 281 282 run := &runStatus{ 283 ctx: ctx, 284 cancel: cancel, 285 step: runStepRunning, 286 spec: tf.Spec.DeepCopy(), 287 entry: entry, 288 startTime: time.Now(), 289 startArgs: entry.Args, 290 tlr: prevResult, 291 } 292 r.runs[nn] = run 293 go r.run(ctx, nn, tf, run, entry) 294 } 295 296 // Executes the tiltfile on a non-blocking goroutine, and requests reconciliation on completion. 297 func (r *Reconciler) run(ctx context.Context, nn types.NamespacedName, tf *v1alpha1.Tiltfile, run *runStatus, entry *BuildEntry) { 298 startTime := time.Now() 299 r.st.Dispatch(ConfigsReloadStartedAction{ 300 Name: entry.Name, 301 FilesChanged: entry.FilesChanged, 302 StartTime: startTime, 303 SpanID: SpanIDForLoadCount(entry.Name, entry.LoadCount), 304 Reason: entry.BuildReason, 305 }) 306 307 buildcontrols.LogBuildEntry(ctx, buildcontrols.BuildEntry{ 308 Name: entry.Name, 309 BuildReason: entry.BuildReason, 310 FilesChanged: entry.FilesChanged, 311 }) 312 313 if entry.BuildReason.Has(model.BuildReasonFlagTiltfileArgs) { 314 logger.Get(ctx).Infof("Tiltfile args changed to: %v", entry.Args) 315 } 316 317 tlr := r.tfl.Load(ctx, tf, run.tlr) 318 319 // If the user is executing an empty main tiltfile, that probably means 320 // they need a tutorial. For now, we link to that tutorial, but a more interactive 321 // system might make sense here. 322 if tlr.Error == nil && len(tlr.Manifests) == 0 && tf.Name == model.MainTiltfileManifestName.String() { 323 tlr.Error = fmt.Errorf("No resources found. Check out https://docs.tilt.dev/tutorial.html to get started!") 324 } 325 326 if tlr.HasOrchestrator(model.OrchestratorK8s) { 327 r.dockerClient.SetOrchestrator(model.OrchestratorK8s) 328 } else if tlr.HasOrchestrator(model.OrchestratorDC) { 329 r.dockerClient.SetOrchestrator(model.OrchestratorDC) 330 } 331 332 if requiresDocker(tlr) { 333 dockerErr := r.dockerClient.CheckConnected() 334 var serverVersion dockertypes.Version 335 if dockerErr == nil { 336 serverVersion, dockerErr = r.dockerClient.ServerVersion(ctx) 337 } 338 if tlr.Error == nil && dockerErr != nil { 339 tlr.Error = errors.Wrap(dockerErr, "Failed to connect to Docker") 340 } 341 r.reportDockerConnectionEvent(ctx, dockerErr == nil, serverVersion) 342 } 343 344 if ctx.Err() == context.Canceled { 345 tlr.Error = errors.New("build canceled") 346 } 347 348 r.mu.Lock() 349 run.tlr = &tlr 350 run.step = runStepLoaded 351 r.mu.Unlock() 352 353 // Schedule a reconcile to create the API objects. 354 r.requeuer.Add(nn) 355 } 356 357 // After the tiltfile has been evaluated, create all the objects in the 358 // apiserver. 359 func (r *Reconciler) handleLoaded( 360 ctx context.Context, 361 nn types.NamespacedName, 362 tf *v1alpha1.Tiltfile, 363 entry *BuildEntry, 364 tlr *tiltfile.TiltfileLoadResult) error { 365 // TODO(nick): Rewrite to handle multiple tiltfiles. 366 changeEnabledResources := entry.ArgsChanged && tlr != nil && tlr.Error == nil 367 err := updateOwnedObjects(ctx, r.ctrlClient, nn, tf, tlr, changeEnabledResources, r.ciTimeoutFlag, r.engineMode, 368 r.defaultK8sConnection()) 369 if err != nil { 370 // If updating the API server fails, just return the error, so that the 371 // reconciler will retry. 372 return errors.Wrap(err, "Failed to update API server") 373 } 374 375 if tlr.Error != nil { 376 logger.Get(ctx).Errorf("%s", tlr.Error.Error()) 377 } 378 379 r.st.Dispatch(ConfigsReloadedAction{ 380 Name: entry.Name, 381 Manifests: tlr.Manifests, 382 Tiltignore: tlr.Tiltignore, 383 ConfigFiles: tlr.ConfigFiles, 384 FinishTime: time.Now(), 385 Err: tlr.Error, 386 Features: tlr.FeatureFlags, 387 TeamID: tlr.TeamID, 388 TelemetrySettings: tlr.TelemetrySettings, 389 Secrets: tlr.Secrets, 390 AnalyticsTiltfileOpt: tlr.AnalyticsOpt, 391 DockerPruneSettings: tlr.DockerPruneSettings, 392 CheckpointAtExecStart: entry.CheckpointAtExecStart, 393 VersionSettings: tlr.VersionSettings, 394 UpdateSettings: tlr.UpdateSettings, 395 WatchSettings: tlr.WatchSettings, 396 }) 397 398 run, ok := r.runs[nn] 399 if ok { 400 run.step = runStepDone 401 run.finishTime = time.Now() 402 } 403 404 // Schedule a reconcile in case any triggers happened while we were updating 405 // API objects. 406 r.requeuer.Add(nn) 407 408 return nil 409 } 410 411 // Cancel execution of a running tiltfile and delete all record of it. 412 func (r *Reconciler) deleteExistingRun(nn types.NamespacedName) { 413 run, ok := r.runs[nn] 414 if !ok { 415 return 416 } 417 delete(r.runs, nn) 418 run.cancel() 419 } 420 421 // Find all the objects we need to watch based on the tiltfile model. 422 func indexTiltfile(obj client.Object) []indexer.Key { 423 return nil 424 } 425 426 // Find any objects we need to reconcile based on the trigger queue. 427 func (r *Reconciler) enqueueTriggerQueue(ctx context.Context, obj client.Object) []reconcile.Request { 428 cm, ok := obj.(*v1alpha1.ConfigMap) 429 if !ok { 430 return nil 431 } 432 433 if cm.Name != configmap.TriggerQueueName { 434 return nil 435 } 436 437 // We can only trigger tiltfiles that have run once, so search 438 // through the map of known tiltfiles. 439 names := configmap.NamesInTriggerQueue(cm) 440 r.mu.Lock() 441 defer r.mu.Unlock() 442 443 requests := []reconcile.Request{} 444 for _, name := range names { 445 nn := types.NamespacedName{Name: name} 446 _, ok := r.runs[nn] 447 if ok { 448 requests = append(requests, reconcile.Request{NamespacedName: nn}) 449 } 450 } 451 return requests 452 } 453 454 // The kubernetes connection defined by the CLI. 455 func (r *Reconciler) defaultK8sConnection() *v1alpha1.KubernetesClusterConnection { 456 return &v1alpha1.KubernetesClusterConnection{ 457 Context: string(r.k8sContextOverride), 458 Namespace: string(r.k8sNamespaceOverride), 459 } 460 } 461 462 func requiresDocker(tlr tiltfile.TiltfileLoadResult) bool { 463 if tlr.HasOrchestrator(model.OrchestratorDC) { 464 return true 465 } 466 467 for _, m := range tlr.Manifests { 468 for _, iTarget := range m.ImageTargets { 469 if iTarget.IsDockerBuild() { 470 return true 471 } 472 } 473 } 474 475 return false 476 } 477 478 // Represent the steps of Tiltfile execution. 479 type runStep int 480 481 const ( 482 // Tiltfile is waiting for first execution. 483 runStepNone runStep = iota 484 485 // We're currently running this tiltfile. 486 runStepRunning 487 488 // The tiltfile is loaded, but the results haven't been 489 // sent to the API server. 490 runStepLoaded 491 492 // The tiltfile has created all owned objects, and may now be restarted. 493 runStepDone 494 ) 495 496 type runStatus struct { 497 ctx context.Context 498 cancel func() 499 step runStep 500 spec *v1alpha1.TiltfileSpec 501 entry *BuildEntry 502 tlr *tiltfile.TiltfileLoadResult 503 startTime time.Time 504 startArgs []string 505 finishTime time.Time 506 } 507 508 func (rs *runStatus) TiltfileStatus() v1alpha1.TiltfileStatus { 509 switch rs.step { 510 case runStepRunning, runStepLoaded: 511 return v1alpha1.TiltfileStatus{ 512 Running: &v1alpha1.TiltfileStateRunning{ 513 StartedAt: apis.NewMicroTime(rs.startTime), 514 }, 515 } 516 case runStepDone: 517 error := "" 518 if rs.tlr.Error != nil { 519 error = rs.tlr.Error.Error() 520 } 521 return v1alpha1.TiltfileStatus{ 522 Terminated: &v1alpha1.TiltfileStateTerminated{ 523 StartedAt: apis.NewMicroTime(rs.startTime), 524 FinishedAt: apis.NewMicroTime(rs.finishTime), 525 Error: error, 526 }, 527 } 528 } 529 530 return v1alpha1.TiltfileStatus{ 531 Waiting: &v1alpha1.TiltfileStateWaiting{ 532 Reason: "Unknown", 533 }, 534 } 535 }