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  }