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

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