github.com/tilt-dev/tilt@v0.33.15-0.20240515162809-0a22ed45d8a0/internal/controllers/apis/trigger/trigger.go (about)

     1  package trigger
     2  
     3  import (
     4  	"context"
     5  	"time"
     6  
     7  	apierrors "k8s.io/apimachinery/pkg/api/errors"
     8  	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
     9  	"k8s.io/apimachinery/pkg/runtime/schema"
    10  	"k8s.io/apimachinery/pkg/types"
    11  	"sigs.k8s.io/controller-runtime/pkg/builder"
    12  	"sigs.k8s.io/controller-runtime/pkg/client"
    13  	"sigs.k8s.io/controller-runtime/pkg/handler"
    14  
    15  	"github.com/tilt-dev/tilt/internal/controllers/indexer"
    16  	"github.com/tilt-dev/tilt/internal/sliceutils"
    17  	"github.com/tilt-dev/tilt/internal/timecmp"
    18  	"github.com/tilt-dev/tilt/pkg/apis/core/v1alpha1"
    19  )
    20  
    21  var fwGVK = v1alpha1.SchemeGroupVersion.WithKind("FileWatch")
    22  var btnGVK = v1alpha1.SchemeGroupVersion.WithKind("UIButton")
    23  
    24  // SetupControllerRestartOn sets up watchers / indexers for a type with a RestartOnSpec
    25  func SetupControllerRestartOn(builder *builder.Builder, idxer *indexer.Indexer, extract func(obj client.Object) *v1alpha1.RestartOnSpec) {
    26  	idxer.AddKeyFunc(
    27  		func(obj client.Object) []indexer.Key {
    28  			spec := extract(obj)
    29  			if spec == nil {
    30  				return nil
    31  			}
    32  			var keys []indexer.Key
    33  			keys = append(keys, indexerKeys(fwGVK, obj.GetNamespace(), spec.FileWatches)...)
    34  			keys = append(keys, indexerKeys(btnGVK, obj.GetNamespace(), spec.UIButtons)...)
    35  			return keys
    36  		})
    37  
    38  	registerWatches(builder, idxer, []client.Object{&v1alpha1.FileWatch{}, &v1alpha1.UIButton{}})
    39  }
    40  
    41  // SetupControllerStartOn sets up watchers / indexers for a type with a StartOnSpec
    42  func SetupControllerStartOn(builder *builder.Builder, idxer *indexer.Indexer, extract func(obj client.Object) *v1alpha1.StartOnSpec) {
    43  	idxer.AddKeyFunc(
    44  		func(obj client.Object) []indexer.Key {
    45  			spec := extract(obj)
    46  			if spec == nil {
    47  				return nil
    48  			}
    49  			return indexerKeys(btnGVK, obj.GetNamespace(), spec.UIButtons)
    50  		})
    51  
    52  	registerWatches(builder, idxer, []client.Object{&v1alpha1.UIButton{}})
    53  }
    54  
    55  // SetupControllerStopOn sets up watchers / indexers for a type with a StopOnSpec
    56  func SetupControllerStopOn(builder *builder.Builder, idxer *indexer.Indexer, extract func(obj client.Object) *v1alpha1.StopOnSpec) {
    57  	idxer.AddKeyFunc(
    58  		func(obj client.Object) []indexer.Key {
    59  			spec := extract(obj)
    60  			if spec == nil {
    61  				return nil
    62  			}
    63  			return indexerKeys(btnGVK, obj.GetNamespace(), spec.UIButtons)
    64  		})
    65  
    66  	registerWatches(builder, idxer, []client.Object{&v1alpha1.UIButton{}})
    67  }
    68  
    69  func indexerKeys(gvk schema.GroupVersionKind, namespace string, names []string) []indexer.Key {
    70  	var keys []indexer.Key
    71  	for _, name := range names {
    72  		keys = append(keys, indexer.Key{
    73  			Name: types.NamespacedName{Namespace: namespace, Name: name},
    74  			GVK:  gvk,
    75  		})
    76  	}
    77  	return keys
    78  }
    79  
    80  func registerWatches(builder *builder.Builder, idxer *indexer.Indexer, typesToWatch []client.Object) {
    81  	for _, t := range typesToWatch {
    82  		builder.Watches(t, handler.EnqueueRequestsFromMapFunc(idxer.Enqueue))
    83  	}
    84  }
    85  
    86  // fetchButtons retrieves all the buttons that this object depends on.
    87  //
    88  // If a button isn't in the API server yet, it will simply be missing from the map.
    89  //
    90  // Other errors reaching the API server will be returned to the caller.
    91  //
    92  // TODO(nick): If the user typos a button name, there's currently no feedback
    93  //
    94  //	that this is happening. This is probably the correct product behavior (in particular:
    95  //	resources should still run if one of their triggers has been deleted).
    96  //	We might eventually need trigger statuses to express errors in lookup.
    97  func fetchButtons(ctx context.Context, client client.Reader, buttonNames []string) (map[string]*v1alpha1.UIButton, error) {
    98  	buttons := make(map[string]*v1alpha1.UIButton, len(buttonNames))
    99  	for _, n := range buttonNames {
   100  		_, exists := buttons[n]
   101  		if exists {
   102  			continue
   103  		}
   104  
   105  		b := &v1alpha1.UIButton{}
   106  		err := client.Get(ctx, types.NamespacedName{Name: n}, b)
   107  		if err != nil {
   108  			if apierrors.IsNotFound(err) {
   109  				continue
   110  			}
   111  			return nil, err
   112  		}
   113  		buttons[n] = b
   114  	}
   115  	return buttons, nil
   116  }
   117  
   118  // fetchFileWatches retrieves all the filewatches that this object depends on.
   119  //
   120  // If a filewatch isn't in the API server yet, it will simply be missing from the slice.
   121  //
   122  // Other errors reaching the API server will be returned to the caller.
   123  //
   124  // TODO(nick): If the user typos a filewatch name, there's currently no feedback
   125  //
   126  //	that this is happening. This is probably the correct product behavior (in particular:
   127  //	resources should still run if one of their triggers has been deleted).
   128  //	We might eventually need trigger statuses to express errors in lookup.
   129  func fetchFileWatches(ctx context.Context, client client.Reader, fwNames []string) ([]*v1alpha1.FileWatch, error) {
   130  	result := []*v1alpha1.FileWatch{}
   131  	for _, n := range fwNames {
   132  		fw := &v1alpha1.FileWatch{}
   133  		err := client.Get(ctx, types.NamespacedName{Name: n}, fw)
   134  		if err != nil {
   135  			if apierrors.IsNotFound(err) {
   136  				continue
   137  			}
   138  			return nil, err
   139  		}
   140  		result = append(result, fw)
   141  	}
   142  	return result, nil
   143  }
   144  
   145  // LastStartEvent determines the last time a start was requested from this target's dependencies.
   146  //
   147  // Returns the most recent start time. If the most recent start is from a button,
   148  // return the button. Some consumers use the button for text inputs.
   149  func LastStartEvent(ctx context.Context, cli client.Reader, startOn *v1alpha1.StartOnSpec) (metav1.MicroTime, *v1alpha1.UIButton, error) {
   150  	if startOn == nil {
   151  		return metav1.MicroTime{}, nil, nil
   152  	}
   153  
   154  	buttons, err := fetchButtons(ctx, cli, startOn.UIButtons)
   155  	if err != nil {
   156  		return metav1.MicroTime{}, nil, err
   157  	}
   158  
   159  	var latestTime metav1.MicroTime
   160  	var latestButton *v1alpha1.UIButton
   161  
   162  	// ensure predictable iteration order by using the list from the spec
   163  	// (currently, missing buttons are simply ignored)
   164  	for _, buttonName := range startOn.UIButtons {
   165  		b := buttons[buttonName]
   166  		if b == nil {
   167  			continue
   168  		}
   169  
   170  		lastEventTime := b.Status.LastClickedAt
   171  		if timecmp.AfterOrEqual(lastEventTime, startOn.StartAfter) && timecmp.After(lastEventTime, latestTime) {
   172  			latestTime = lastEventTime
   173  			latestButton = b
   174  		}
   175  	}
   176  
   177  	return latestTime, latestButton, nil
   178  }
   179  
   180  // LastRestartEvent determines the last time a restart was requested from this target's dependencies.
   181  //
   182  // Returns the most recent restart time.
   183  //
   184  // If the most recent restart is from a button, return the button. Some consumers use the button for text inputs.
   185  // If the most recent restart is from a FileWatch, return the FileWatch. Some consumers use the FileWatch to
   186  // determine if they should re-run or not to avoid repeated failures.
   187  func LastRestartEvent(ctx context.Context, cli client.Reader, restartOn *v1alpha1.RestartOnSpec) (metav1.MicroTime, *v1alpha1.UIButton, []*v1alpha1.FileWatch, error) {
   188  	var fws []*v1alpha1.FileWatch
   189  	if restartOn == nil {
   190  		return metav1.MicroTime{}, nil, fws, nil
   191  	}
   192  	buttons, err := fetchButtons(ctx, cli, restartOn.UIButtons)
   193  	if err != nil {
   194  		return metav1.MicroTime{}, nil, fws, err
   195  	}
   196  	fws, err = fetchFileWatches(ctx, cli, restartOn.FileWatches)
   197  	if err != nil {
   198  		return metav1.MicroTime{}, nil, fws, err
   199  	}
   200  
   201  	var cur metav1.MicroTime
   202  	var latestButton *v1alpha1.UIButton
   203  
   204  	for _, fw := range fws {
   205  		lastEventTime := fw.Status.LastEventTime
   206  		if timecmp.After(lastEventTime, cur) {
   207  			cur = lastEventTime
   208  		}
   209  	}
   210  
   211  	// ensure predictable iteration order by using the list from the spec
   212  	// (currently, missing buttons are simply ignored)
   213  	for _, buttonName := range restartOn.UIButtons {
   214  		b := buttons[buttonName]
   215  		if b == nil {
   216  			continue
   217  		}
   218  
   219  		lastEventTime := b.Status.LastClickedAt
   220  		if timecmp.After(lastEventTime, cur) {
   221  			cur = lastEventTime
   222  			latestButton = b
   223  		}
   224  	}
   225  
   226  	return cur, latestButton, fws, nil
   227  }
   228  
   229  // FilesChanged determines the set of files that have changed since the given timestamp.
   230  // We err on the side of undercounting (i.e., skipping files that may have triggered
   231  // this build but are not sure).
   232  func FilesChanged(restartOn *v1alpha1.RestartOnSpec, fileWatches []*v1alpha1.FileWatch, lastBuild time.Time) []string {
   233  	filesChanged := []string{}
   234  	if restartOn == nil {
   235  		return filesChanged
   236  	}
   237  	for _, fw := range fileWatches {
   238  		// Add files so that the most recent files are first.
   239  		for i := len(fw.Status.FileEvents) - 1; i >= 0; i-- {
   240  			e := fw.Status.FileEvents[i]
   241  			if timecmp.After(e.Time, lastBuild) {
   242  				filesChanged = append(filesChanged, e.SeenFiles...)
   243  			}
   244  		}
   245  	}
   246  	return sliceutils.DedupedAndSorted(filesChanged)
   247  }
   248  
   249  // LastStopEvent determines the latest time a stop was requested from this target's dependencies.
   250  //
   251  // Returns the most recent stop time. If the most recent stop is from a button,
   252  // return the button. Some consumers use the button for text inputs.
   253  func LastStopEvent(ctx context.Context, cli client.Reader, stopOn *v1alpha1.StopOnSpec) (metav1.MicroTime, *v1alpha1.UIButton, error) {
   254  	if stopOn == nil {
   255  		return metav1.MicroTime{}, nil, nil
   256  	}
   257  
   258  	buttons, err := fetchButtons(ctx, cli, stopOn.UIButtons)
   259  	if err != nil {
   260  		return metav1.MicroTime{}, nil, err
   261  	}
   262  
   263  	var latestTime metav1.MicroTime
   264  	var latestButton *v1alpha1.UIButton
   265  
   266  	// ensure predictable iteration order by using the list from the spec
   267  	// (currently, missing buttons are simply ignored)
   268  	for _, buttonName := range stopOn.UIButtons {
   269  		b := buttons[buttonName]
   270  		if b == nil {
   271  			continue
   272  		}
   273  
   274  		lastEventTime := b.Status.LastClickedAt
   275  		if timecmp.After(lastEventTime, latestTime) {
   276  			latestTime = lastEventTime
   277  			latestButton = b
   278  		}
   279  	}
   280  
   281  	return latestTime, latestButton, nil
   282  }