github.com/tilt-dev/tilt@v0.33.15-0.20240515162809-0a22ed45d8a0/internal/engine/buildcontroller.go (about) 1 package engine 2 3 import ( 4 "context" 5 "fmt" 6 "sort" 7 "sync" 8 "time" 9 10 "github.com/tilt-dev/tilt/internal/timecmp" 11 12 "github.com/pkg/errors" 13 14 "github.com/tilt-dev/tilt/internal/controllers/apis/uibutton" 15 "github.com/tilt-dev/tilt/internal/engine/buildcontrol" 16 "github.com/tilt-dev/tilt/internal/store" 17 "github.com/tilt-dev/tilt/internal/store/buildcontrols" 18 "github.com/tilt-dev/tilt/internal/store/k8sconv" 19 "github.com/tilt-dev/tilt/pkg/apis/core/v1alpha1" 20 "github.com/tilt-dev/tilt/pkg/model" 21 "github.com/tilt-dev/tilt/pkg/model/logstore" 22 ) 23 24 const BuildControlSource = "buildcontrol" 25 26 type BuildController struct { 27 b buildcontrol.BuildAndDeployer 28 buildsStartedCount int // used to synchronize with state 29 disabledForTesting bool 30 31 // CancelFuncs for in-progress builds 32 mu sync.Mutex 33 stopBuildFns map[model.ManifestName]context.CancelFunc 34 } 35 36 type buildEntry struct { 37 name model.ManifestName 38 targets []model.TargetSpec 39 buildStateSet store.BuildStateSet 40 filesChanged []string 41 buildReason model.BuildReason 42 spanID logstore.SpanID 43 } 44 45 func (e buildEntry) Name() model.ManifestName { return e.name } 46 func (e buildEntry) FilesChanged() []string { return e.filesChanged } 47 func (e buildEntry) BuildReason() model.BuildReason { return e.buildReason } 48 49 func NewBuildController(b buildcontrol.BuildAndDeployer) *BuildController { 50 return &BuildController{ 51 b: b, 52 stopBuildFns: make(map[model.ManifestName]context.CancelFunc), 53 } 54 } 55 56 func (c *BuildController) needsBuild(ctx context.Context, st store.RStore) (buildEntry, bool) { 57 state := st.RLockState() 58 defer st.RUnlockState() 59 60 // Don't start the next build until the previous action has been recorded, 61 // so that we don't accidentally repeat the same build. 62 if c.buildsStartedCount > state.BuildControllerStartCount { 63 return buildEntry{}, false 64 } 65 66 // no build slots available 67 if state.AvailableBuildSlots() < 1 { 68 return buildEntry{}, false 69 } 70 71 mt, _ := buildcontrol.NextTargetToBuild(state) 72 if mt == nil { 73 return buildEntry{}, false 74 } 75 76 c.buildsStartedCount += 1 77 ms := mt.State 78 manifest := mt.Manifest 79 80 buildReason := mt.NextBuildReason() 81 targets := buildcontrol.BuildTargets(manifest) 82 buildStateSet := buildStateSet(ctx, 83 manifest, 84 state.KubernetesResources[manifest.Name.String()], 85 state.DockerComposeServices[manifest.Name.String()], 86 state.Clusters[manifest.ClusterName()], 87 targets, 88 ms, 89 buildReason) 90 91 return buildEntry{ 92 name: manifest.Name, 93 targets: targets, 94 buildReason: buildReason, 95 buildStateSet: buildStateSet, 96 filesChanged: append(ms.ConfigFilesThatCausedChange, buildStateSet.FilesChanged()...), 97 spanID: SpanIDForBuildLog(c.buildsStartedCount), 98 }, true 99 } 100 101 func (c *BuildController) DisableForTesting() { 102 c.disabledForTesting = true 103 } 104 105 func (c *BuildController) OnChange(ctx context.Context, st store.RStore, summary store.ChangeSummary) error { 106 if summary.IsLogOnly() { 107 return nil 108 } 109 110 c.cleanUpCanceledBuilds(st) 111 112 if c.disabledForTesting { 113 return nil 114 } 115 entry, ok := c.needsBuild(ctx, st) 116 if !ok { 117 return nil 118 } 119 120 st.Dispatch(buildcontrols.BuildStartedAction{ 121 ManifestName: entry.name, 122 StartTime: time.Now(), 123 FilesChanged: entry.filesChanged, 124 Reason: entry.buildReason, 125 SpanID: entry.spanID, 126 FullBuildTriggered: entry.buildStateSet.FullBuildTriggered(), 127 Source: BuildControlSource, 128 }) 129 130 go func() { 131 ctx = c.buildContext(ctx, entry, st) 132 defer c.cleanupBuildContext(entry.name) 133 134 buildcontrols.LogBuildEntry(ctx, buildcontrols.BuildEntry{ 135 Name: entry.Name(), 136 BuildReason: entry.BuildReason(), 137 FilesChanged: entry.FilesChanged(), 138 }) 139 140 result, err := c.buildAndDeploy(ctx, st, entry) 141 if ctx.Err() == context.Canceled { 142 err = errors.New("build canceled") 143 } 144 st.Dispatch(buildcontrols.NewBuildCompleteAction(entry.name, BuildControlSource, entry.spanID, result, err)) 145 }() 146 147 return nil 148 } 149 150 func (c *BuildController) buildAndDeploy(ctx context.Context, st store.RStore, entry buildEntry) (store.BuildResultSet, error) { 151 targets := entry.targets 152 for _, target := range targets { 153 err := target.Validate() 154 if err != nil { 155 return store.BuildResultSet{}, err 156 } 157 } 158 return c.b.BuildAndDeploy(ctx, st, targets, entry.buildStateSet) 159 } 160 161 // cancel any in-progress builds associated with canceled builds and disabled UIResources 162 // when builds are fully represented by api objects, cancellation should probably 163 // be tied to those rather than the UIResource 164 func (c *BuildController) cleanUpCanceledBuilds(st store.RStore) { 165 state := st.RLockState() 166 defer st.RUnlockState() 167 168 for _, ms := range state.ManifestStates() { 169 if !ms.IsBuilding() { 170 continue 171 } 172 disabled := ms.DisableState == v1alpha1.DisableStateDisabled 173 canceled := false 174 if cancelButton, ok := state.UIButtons[uibutton.StopBuildButtonName(ms.Name.String())]; ok { 175 lastCancelClick := cancelButton.Status.LastClickedAt 176 canceled = timecmp.AfterOrEqual(lastCancelClick, ms.EarliestCurrentBuild().StartTime) 177 } 178 if disabled || canceled { 179 c.cleanupBuildContext(ms.Name) 180 } 181 } 182 } 183 184 func (c *BuildController) buildContext(ctx context.Context, entry buildEntry, st store.RStore) context.Context { 185 // Send the logs to both the EngineState and the normal log stream. 186 ctx = store.WithManifestLogHandler(ctx, st, entry.name, entry.spanID) 187 188 ctx, cancel := context.WithCancel(ctx) 189 c.mu.Lock() 190 defer c.mu.Unlock() 191 c.stopBuildFns[entry.name] = cancel 192 return ctx 193 } 194 195 func (c *BuildController) cleanupBuildContext(mn model.ManifestName) { 196 c.mu.Lock() 197 defer c.mu.Unlock() 198 if cancel, ok := c.stopBuildFns[mn]; ok { 199 cancel() 200 delete(c.stopBuildFns, mn) 201 } 202 } 203 204 func SpanIDForBuildLog(buildCount int) logstore.SpanID { 205 return logstore.SpanID(fmt.Sprintf("build:%d", buildCount)) 206 } 207 208 // Extract a set of build states from a manifest for BuildAndDeploy. 209 func buildStateSet(ctx context.Context, manifest model.Manifest, 210 kresource *k8sconv.KubernetesResource, 211 dcs *v1alpha1.DockerComposeService, 212 cluster *v1alpha1.Cluster, 213 specs []model.TargetSpec, 214 ms *store.ManifestState, reason model.BuildReason) store.BuildStateSet { 215 result := store.BuildStateSet{} 216 217 for _, spec := range specs { 218 id := spec.ID() 219 status := ms.BuildStatus(id) 220 var filesChanged []string 221 for file := range status.PendingFileChanges { 222 filesChanged = append(filesChanged, file) 223 } 224 sort.Strings(filesChanged) 225 226 var depsChanged []model.TargetID 227 for dep := range status.PendingDependencyChanges { 228 depsChanged = append(depsChanged, dep) 229 } 230 231 state := store.NewBuildState(status.LastResult, filesChanged, depsChanged) 232 state.Cluster = cluster 233 result[id] = state 234 } 235 236 isFullBuildTrigger := reason.HasTrigger() && !buildcontrol.IsLiveUpdateEligibleTrigger(manifest, reason) 237 if isFullBuildTrigger { 238 for k, v := range result { 239 result[k] = v.WithFullBuildTriggered(true) 240 } 241 } 242 243 return result 244 } 245 246 var _ store.Subscriber = &BuildController{}