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