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