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{}