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 }