github.com/tilt-dev/tilt@v0.33.15-0.20240515162809-0a22ed45d8a0/internal/engine/build_and_deployer_test.go (about)

     1  package engine
     2  
     3  import (
     4  	"archive/tar"
     5  	"bytes"
     6  	"context"
     7  	"fmt"
     8  	"io"
     9  	"path/filepath"
    10  	"strings"
    11  	"testing"
    12  	"time"
    13  
    14  	"github.com/distribution/reference"
    15  	"github.com/docker/docker/api/types"
    16  	"github.com/opencontainers/go-digest"
    17  	"github.com/pkg/errors"
    18  	"github.com/stretchr/testify/assert"
    19  	"github.com/stretchr/testify/require"
    20  	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
    21  	ctrlclient "sigs.k8s.io/controller-runtime/pkg/client"
    22  
    23  	"github.com/tilt-dev/tilt/internal/controllers/apis/liveupdate"
    24  	"github.com/tilt-dev/tilt/internal/controllers/fake"
    25  	"github.com/tilt-dev/tilt/internal/engine/buildcontrol"
    26  	"github.com/tilt-dev/tilt/internal/localexec"
    27  	"github.com/tilt-dev/tilt/internal/store/liveupdates"
    28  
    29  	"github.com/tilt-dev/wmclient/pkg/dirs"
    30  
    31  	"github.com/tilt-dev/clusterid"
    32  	"github.com/tilt-dev/tilt/internal/container"
    33  	"github.com/tilt-dev/tilt/internal/dockercompose"
    34  	"github.com/tilt-dev/tilt/internal/k8s/testyaml"
    35  	"github.com/tilt-dev/tilt/internal/store"
    36  
    37  	"github.com/tilt-dev/tilt/internal/docker"
    38  	"github.com/tilt-dev/tilt/internal/k8s"
    39  	"github.com/tilt-dev/tilt/internal/testutils"
    40  	"github.com/tilt-dev/tilt/internal/testutils/manifestbuilder"
    41  	"github.com/tilt-dev/tilt/internal/testutils/tempdir"
    42  	"github.com/tilt-dev/tilt/pkg/apis"
    43  	"github.com/tilt-dev/tilt/pkg/apis/core/v1alpha1"
    44  	"github.com/tilt-dev/tilt/pkg/model"
    45  )
    46  
    47  var testImageRef = container.MustParseNamedTagged("gcr.io/some-project-162817/sancho:deadbeef")
    48  var imageTargetID = model.TargetID{
    49  	Type: model.TargetTypeImage,
    50  	Name: model.TargetName(apis.SanitizeName("gcr.io/some-project-162817/sancho")),
    51  }
    52  
    53  var alreadyBuilt = store.NewImageBuildResultSingleRef(imageTargetID, testImageRef)
    54  var alreadyBuiltSet = store.BuildResultSet{imageTargetID: alreadyBuilt}
    55  
    56  type expectedFile = testutils.ExpectedFile
    57  
    58  func TestGKEDeploy(t *testing.T) {
    59  	f := newBDFixture(t, clusterid.ProductGKE, container.RuntimeDocker)
    60  
    61  	manifest := NewSanchoLiveUpdateManifest(f)
    62  	targets := buildcontrol.BuildTargets(manifest)
    63  	_, err := f.BuildAndDeploy(targets, store.BuildStateSet{})
    64  	if err != nil {
    65  		t.Fatal(err)
    66  	}
    67  
    68  	if f.docker.BuildCount != 1 {
    69  		t.Errorf("Expected 1 docker build, actual: %d", f.docker.BuildCount)
    70  	}
    71  
    72  	if f.docker.PushCount != 1 {
    73  		t.Errorf("Expected 1 push to docker, actual: %d", f.docker.PushCount)
    74  	}
    75  
    76  	expectedYaml := "image: gcr.io/some-project-162817/sancho:tilt-11cd0b38bc3ceb95"
    77  	if !strings.Contains(f.k8s.Yaml, expectedYaml) {
    78  		t.Errorf("Expected yaml to contain %q. Actual:\n%s", expectedYaml, f.k8s.Yaml)
    79  	}
    80  }
    81  
    82  func TestYamlManifestDeploy(t *testing.T) {
    83  	f := newBDFixture(t, clusterid.ProductGKE, container.RuntimeDocker)
    84  
    85  	manifest := manifestbuilder.New(f, "some_yaml").
    86  		WithK8sYAML(testyaml.TracerYAML).Build()
    87  	targets := buildcontrol.BuildTargets(manifest)
    88  	_, err := f.BuildAndDeploy(targets, store.BuildStateSet{})
    89  	if err != nil {
    90  		t.Fatal(err)
    91  	}
    92  
    93  	assert.Equal(t, 0, f.docker.BuildCount)
    94  	assert.Equal(t, 0, f.docker.PushCount)
    95  	f.assertK8sUpsertCalled(true)
    96  }
    97  
    98  func TestFallBackToImageDeploy(t *testing.T) {
    99  	f := newBDFixture(t, clusterid.ProductDockerDesktop, container.RuntimeDocker)
   100  
   101  	f.docker.SetExecError(errors.New("some random error"))
   102  
   103  	manifest := NewSanchoLiveUpdateManifest(f)
   104  	changed := f.WriteFile("a.txt", "a")
   105  	bs := resultToStateSet(manifest, alreadyBuiltSet, []string{changed})
   106  
   107  	targets := buildcontrol.BuildTargets(manifest)
   108  	_, err := f.BuildAndDeploy(targets, bs)
   109  	if err != nil {
   110  		t.Fatal(err)
   111  	}
   112  
   113  	f.assertContainerRestarts(0)
   114  	if f.docker.BuildCount != 1 {
   115  		t.Errorf("Expected 1 docker build, actual: %d", f.docker.BuildCount)
   116  	}
   117  }
   118  
   119  func TestIgnoredFiles(t *testing.T) {
   120  	f := newBDFixture(t, clusterid.ProductDockerDesktop, container.RuntimeDocker)
   121  
   122  	manifest := NewSanchoDockerBuildManifest(f)
   123  
   124  	tiltfile := filepath.Join(f.Path(), "Tiltfile")
   125  	manifest = manifest.WithImageTarget(manifest.ImageTargetAt(0).WithIgnores([]v1alpha1.IgnoreDef{
   126  		{BasePath: filepath.Join(f.Path(), ".git")},
   127  		{BasePath: tiltfile},
   128  	}))
   129  
   130  	f.WriteFile("Tiltfile", "# hello world")
   131  	f.WriteFile("a.txt", "a")
   132  	f.WriteFile(".git/index", "garbage")
   133  
   134  	targets := buildcontrol.BuildTargets(manifest)
   135  	_, err := f.BuildAndDeploy(targets, store.BuildStateSet{})
   136  	if err != nil {
   137  		t.Fatal(err)
   138  	}
   139  
   140  	tr := tar.NewReader(f.docker.BuildContext)
   141  	testutils.AssertFilesInTar(t, tr, []expectedFile{
   142  		expectedFile{
   143  			Path:     "a.txt",
   144  			Contents: "a",
   145  		},
   146  		expectedFile{
   147  			Path:    ".git/index",
   148  			Missing: true,
   149  		},
   150  		expectedFile{
   151  			Path:    "Tiltfile",
   152  			Missing: true,
   153  		},
   154  	})
   155  }
   156  
   157  func TestCustomBuild(t *testing.T) {
   158  	f := newBDFixture(t, clusterid.ProductGKE, container.RuntimeDocker)
   159  	sha := digest.Digest("sha256:11cd0eb38bc3ceb958ffb2f9bd70be3fb317ce7d255c8a4c3f4af30e298aa1aab")
   160  	f.docker.Images["gcr.io/some-project-162817/sancho:tilt-build-1551202573"] = types.ImageInspect{ID: string(sha)}
   161  
   162  	manifest := NewSanchoCustomBuildManifest(f)
   163  	targets := buildcontrol.BuildTargets(manifest)
   164  
   165  	_, err := f.BuildAndDeploy(targets, store.BuildStateSet{})
   166  	if err != nil {
   167  		t.Fatal(err)
   168  	}
   169  
   170  	if f.docker.BuildCount != 0 {
   171  		t.Errorf("Expected 0 docker build, actual: %d", f.docker.BuildCount)
   172  	}
   173  
   174  	if f.docker.PushCount != 1 {
   175  		t.Errorf("Expected 1 push to docker, actual: %d", f.docker.PushCount)
   176  	}
   177  }
   178  
   179  func TestCustomBuildDeterministicTag(t *testing.T) {
   180  	f := newBDFixture(t, clusterid.ProductGKE, container.RuntimeDocker)
   181  	refStr := "gcr.io/some-project-162817/sancho:deterministic-tag"
   182  	sha := digest.Digest("sha256:11cd0eb38bc3ceb958ffb2f9bd70be3fb317ce7d255c8a4c3f4af30e298aa1aab")
   183  	f.docker.Images[refStr] = types.ImageInspect{ID: string(sha)}
   184  
   185  	manifest := NewSanchoCustomBuildManifestWithTag(f, "deterministic-tag")
   186  	targets := buildcontrol.BuildTargets(manifest)
   187  
   188  	_, err := f.BuildAndDeploy(targets, store.BuildStateSet{})
   189  	if err != nil {
   190  		t.Fatal(err)
   191  	}
   192  
   193  	if f.docker.BuildCount != 0 {
   194  		t.Errorf("Expected 0 docker build, actual: %d", f.docker.BuildCount)
   195  	}
   196  
   197  	if f.docker.PushCount != 1 {
   198  		t.Errorf("Expected 1 push to docker, actual: %d", f.docker.PushCount)
   199  	}
   200  }
   201  
   202  func TestDockerComposeImageBuild(t *testing.T) {
   203  	f := newBDFixture(t, clusterid.ProductGKE, container.RuntimeDocker)
   204  
   205  	manifest := NewSanchoLiveUpdateDCManifest(f)
   206  	targets := buildcontrol.BuildTargets(manifest)
   207  
   208  	_, err := f.BuildAndDeploy(targets, store.BuildStateSet{})
   209  	if err != nil {
   210  		t.Fatal(err)
   211  	}
   212  
   213  	assert.Equal(t, 1, f.docker.BuildCount)
   214  	assert.Equal(t, 0, f.docker.PushCount)
   215  	assert.Empty(t, f.k8s.Yaml, "expect no k8s YAML for DockerCompose resource")
   216  	assert.Len(t, f.dcCli.UpCalls(), 1)
   217  }
   218  
   219  func TestReturnLastUnexpectedError(t *testing.T) {
   220  	f := newBDFixture(t, clusterid.ProductDockerDesktop, container.RuntimeDocker)
   221  
   222  	// next Docker build will throw an unexpected error -- this is one we want to return,
   223  	// even if subsequent builders throw expected errors.
   224  	f.docker.BuildErrorToThrow = fmt.Errorf("no one expects the unexpected error")
   225  
   226  	manifest := NewSanchoLiveUpdateManifest(f)
   227  	_, err := f.BuildAndDeploy(buildcontrol.BuildTargets(manifest), store.BuildStateSet{})
   228  	if assert.Error(t, err) {
   229  		assert.Contains(t, err.Error(), "no one expects the unexpected error")
   230  	}
   231  }
   232  
   233  // errors get logged by the upper, so make sure our builder isn't logging the error redundantly
   234  func TestDockerBuildErrorNotLogged(t *testing.T) {
   235  	f := newBDFixture(t, clusterid.ProductGKE, container.RuntimeDocker)
   236  
   237  	// next Docker build will throw an unexpected error -- this is one we want to return,
   238  	// even if subsequent builders throw expected errors.
   239  	f.docker.BuildErrorToThrow = fmt.Errorf("no one expects the unexpected error")
   240  
   241  	manifest := NewSanchoDockerBuildManifest(f)
   242  	_, err := f.BuildAndDeploy(buildcontrol.BuildTargets(manifest), store.BuildStateSet{})
   243  	if assert.Error(t, err) {
   244  		assert.Contains(t, err.Error(), "no one expects the unexpected error")
   245  	}
   246  
   247  	logs := f.logs.String()
   248  	require.Equal(t, 0, strings.Count(logs, "no one expects the unexpected error"))
   249  }
   250  
   251  func TestLocalTargetDeploy(t *testing.T) {
   252  	f := newBDFixture(t, clusterid.ProductGKE, container.RuntimeDocker)
   253  
   254  	lt := model.NewLocalTarget("hello-world", model.ToHostCmd("echo hello world"), model.Cmd{}, nil)
   255  	res, err := f.BuildAndDeploy([]model.TargetSpec{lt}, store.BuildStateSet{})
   256  	require.Nil(t, err)
   257  
   258  	assert.Equal(t, 0, f.docker.BuildCount, "should have 0 docker builds")
   259  	assert.Equal(t, 0, f.docker.PushCount, "should have 0 docker pushes")
   260  	assert.Empty(t, f.k8s.Yaml, "should not apply any k8s yaml")
   261  	assert.Len(t, res, 1, "expect exactly one result in result set")
   262  	assert.Contains(t, f.logs.String(), "hello world", "logs should contain cmd output")
   263  }
   264  
   265  func TestLocalTargetFailure(t *testing.T) {
   266  	f := newBDFixture(t, clusterid.ProductGKE, container.RuntimeDocker)
   267  
   268  	lt := model.NewLocalTarget("hello-world", model.ToHostCmd("echo 'oh no' && exit 1"), model.Cmd{}, nil)
   269  	res, err := f.BuildAndDeploy([]model.TargetSpec{lt}, store.BuildStateSet{})
   270  	assert.Empty(t, res, "expect empty result for failed command")
   271  
   272  	require.NotNil(t, err)
   273  	assert.Contains(t, err.Error(), "exit status 1", "error msg should indicate command failure")
   274  	assert.Contains(t, f.logs.String(), "oh no", "logs should contain cmd output")
   275  
   276  	assert.Equal(t, 0, f.docker.BuildCount, "should have 0 docker builds")
   277  	assert.Equal(t, 0, f.docker.PushCount, "should have 0 docker pushes")
   278  	assert.Empty(t, f.k8s.Yaml, "should not apply any k8s yaml")
   279  }
   280  
   281  type testStore struct {
   282  	*store.TestingStore
   283  	out io.Writer
   284  }
   285  
   286  func NewTestingStore(out io.Writer) *testStore {
   287  	return &testStore{
   288  		TestingStore: store.NewTestingStore(),
   289  		out:          out,
   290  	}
   291  }
   292  
   293  func (s *testStore) Dispatch(action store.Action) {
   294  	s.TestingStore.Dispatch(action)
   295  
   296  	if action, ok := action.(store.LogAction); ok {
   297  		_, _ = s.out.Write(action.Message())
   298  	}
   299  }
   300  
   301  // The API boundaries between BuildAndDeployer and the ImageBuilder aren't obvious and
   302  // are likely to change in the future. So we test them together, using
   303  // a fake Client and K8sClient
   304  type bdFixture struct {
   305  	*tempdir.TempDirFixture
   306  	ctx        context.Context
   307  	cancel     func()
   308  	docker     *docker.FakeClient
   309  	k8s        *k8s.FakeK8sClient
   310  	bd         buildcontrol.BuildAndDeployer
   311  	st         *testStore
   312  	dcCli      *dockercompose.FakeDCClient
   313  	logs       *bytes.Buffer
   314  	ctrlClient ctrlclient.Client
   315  }
   316  
   317  func newBDFixture(t *testing.T, env clusterid.Product, runtime container.Runtime) *bdFixture {
   318  	return newBDFixtureWithUpdateMode(t, env, runtime, liveupdates.UpdateModeAuto)
   319  }
   320  
   321  func newBDFixtureWithUpdateMode(t *testing.T, env clusterid.Product, runtime container.Runtime, um liveupdates.UpdateMode) *bdFixture {
   322  	logs := new(bytes.Buffer)
   323  	ctx, _, ta := testutils.ForkedCtxAndAnalyticsForTest(logs)
   324  	ctx, cancel := context.WithCancel(ctx)
   325  	f := tempdir.NewTempDirFixture(t)
   326  	dir := dirs.NewTiltDevDirAt(f.Path())
   327  	dockerClient := docker.NewFakeClient()
   328  	dockerClient.ContainerListOutput = map[string][]types.Container{
   329  		"pod": []types.Container{
   330  			types.Container{
   331  				ID: k8s.MagicTestContainerID,
   332  			},
   333  		},
   334  	}
   335  	k8s := k8s.NewFakeK8sClient(t)
   336  	k8s.Runtime = runtime
   337  	mode := liveupdates.UpdateModeFlag(um)
   338  	dcc := dockercompose.NewFakeDockerComposeClient(t, ctx)
   339  	kl := &fakeKINDLoader{}
   340  	ctrlClient := fake.NewFakeTiltClient()
   341  	st := NewTestingStore(logs)
   342  	execer := localexec.NewFakeExecer(t)
   343  	bd, err := provideFakeBuildAndDeployer(ctx, dockerClient, k8s, dir, env, mode, dcc,
   344  		fakeClock{now: time.Unix(1551202573, 0)}, kl, ta, ctrlClient, st, execer)
   345  	require.NoError(t, err)
   346  
   347  	ret := &bdFixture{
   348  		TempDirFixture: f,
   349  		ctx:            ctx,
   350  		cancel:         cancel,
   351  		docker:         dockerClient,
   352  		k8s:            k8s,
   353  		bd:             bd,
   354  		st:             st,
   355  		dcCli:          dcc,
   356  		logs:           logs,
   357  		ctrlClient:     ctrlClient,
   358  	}
   359  
   360  	t.Cleanup(ret.TearDown)
   361  	return ret
   362  }
   363  
   364  func (f *bdFixture) TearDown() {
   365  	f.cancel()
   366  }
   367  
   368  func (f *bdFixture) NewPathSet(paths ...string) model.PathSet {
   369  	return model.NewPathSet(paths, f.Path())
   370  }
   371  
   372  func (f *bdFixture) assertContainerRestarts(count int) {
   373  	// Ensure that MagicTestContainerID was the only container id that saw
   374  	// restarts, and that it saw the right number of restarts.
   375  	expected := map[string]int{}
   376  	if count != 0 {
   377  		expected[string(k8s.MagicTestContainerID)] = count
   378  	}
   379  	assert.Equal(f.T(), expected, f.docker.RestartsByContainer,
   380  		"checking for expected # of container restarts")
   381  }
   382  
   383  func (f *bdFixture) assertK8sUpsertCalled(called bool) {
   384  	assert.Equal(f.T(), called, f.k8s.Yaml != "",
   385  		"checking that k8s.Upsert was called")
   386  }
   387  
   388  func (f *bdFixture) upsertSpec(obj ctrlclient.Object) {
   389  	fake.UpsertSpec(f.ctx, f.T(), f.ctrlClient, obj)
   390  }
   391  
   392  func (f *bdFixture) updateStatus(obj ctrlclient.Object) {
   393  	fake.UpdateStatus(f.ctx, f.T(), f.ctrlClient, obj)
   394  }
   395  
   396  func (f *bdFixture) BuildAndDeploy(specs []model.TargetSpec, stateSet store.BuildStateSet) (store.BuildResultSet, error) {
   397  	cluster := &v1alpha1.Cluster{}
   398  	for _, spec := range specs {
   399  		switch spec.(type) {
   400  		case model.DockerComposeTarget:
   401  			cluster.Spec.Connection = &v1alpha1.ClusterConnection{
   402  				Docker: &v1alpha1.DockerClusterConnection{},
   403  			}
   404  		case model.K8sTarget:
   405  			cluster.Spec.Connection = &v1alpha1.ClusterConnection{
   406  				Kubernetes: &v1alpha1.KubernetesClusterConnection{},
   407  			}
   408  		}
   409  	}
   410  
   411  	for _, spec := range specs {
   412  		localTarget, ok := spec.(model.LocalTarget)
   413  		if ok && localTarget.UpdateCmdSpec != nil {
   414  			cmd := v1alpha1.Cmd{
   415  				ObjectMeta: metav1.ObjectMeta{Name: localTarget.UpdateCmdName()},
   416  				Spec:       *(localTarget.UpdateCmdSpec),
   417  			}
   418  			f.upsertSpec(&cmd)
   419  		}
   420  
   421  		iTarget, ok := spec.(model.ImageTarget)
   422  		if ok {
   423  			im := v1alpha1.ImageMap{
   424  				ObjectMeta: metav1.ObjectMeta{Name: iTarget.ID().Name.String()},
   425  				Spec:       iTarget.ImageMapSpec,
   426  			}
   427  			f.upsertSpec(&im)
   428  			state := stateSet[iTarget.ID()]
   429  			state.Cluster = cluster
   430  			stateSet[iTarget.ID()] = state
   431  
   432  			imageBuildResult, ok := state.LastResult.(store.ImageBuildResult)
   433  			if ok {
   434  				im.Status = imageBuildResult.ImageMapStatus
   435  			}
   436  			f.updateStatus(&im)
   437  
   438  			if !liveupdate.IsEmptySpec(iTarget.LiveUpdateSpec) {
   439  				lu := v1alpha1.LiveUpdate{
   440  					ObjectMeta: metav1.ObjectMeta{Name: iTarget.LiveUpdateName},
   441  					Spec:       iTarget.LiveUpdateSpec,
   442  				}
   443  				f.upsertSpec(&lu)
   444  			}
   445  
   446  			if iTarget.IsDockerBuild() {
   447  				di := v1alpha1.DockerImage{
   448  					ObjectMeta: metav1.ObjectMeta{Name: iTarget.DockerImageName},
   449  					Spec:       iTarget.DockerBuildInfo().DockerImageSpec,
   450  				}
   451  				f.upsertSpec(&di)
   452  			}
   453  			if iTarget.IsCustomBuild() {
   454  				cmdImageSpec := iTarget.CustomBuildInfo().CmdImageSpec
   455  				ci := v1alpha1.CmdImage{
   456  					ObjectMeta: metav1.ObjectMeta{Name: iTarget.CmdImageName},
   457  					Spec:       cmdImageSpec,
   458  				}
   459  				f.upsertSpec(&ci)
   460  
   461  				c := v1alpha1.Cmd{
   462  					ObjectMeta: metav1.ObjectMeta{Name: iTarget.CmdImageName},
   463  					Spec: v1alpha1.CmdSpec{
   464  						Args: cmdImageSpec.Args,
   465  						Dir:  cmdImageSpec.Dir,
   466  					},
   467  				}
   468  				f.upsertSpec(&c)
   469  			}
   470  		}
   471  
   472  		kTarget, ok := spec.(model.K8sTarget)
   473  		if ok {
   474  			ka := v1alpha1.KubernetesApply{
   475  				ObjectMeta: metav1.ObjectMeta{Name: kTarget.ID().Name.String()},
   476  				Spec:       kTarget.KubernetesApplySpec,
   477  			}
   478  			f.upsertSpec(&ka)
   479  		}
   480  
   481  		dcTarget, ok := spec.(model.DockerComposeTarget)
   482  		if ok {
   483  			dcs := v1alpha1.DockerComposeService{
   484  				ObjectMeta: metav1.ObjectMeta{Name: dcTarget.ID().Name.String()},
   485  				Spec:       dcTarget.Spec,
   486  			}
   487  			f.upsertSpec(&dcs)
   488  		}
   489  	}
   490  	return f.bd.BuildAndDeploy(f.ctx, f.st, specs, stateSet)
   491  }
   492  
   493  func resultToStateSet(m model.Manifest, resultSet store.BuildResultSet, files []string) store.BuildStateSet {
   494  	stateSet := store.BuildStateSet{}
   495  	for id, result := range resultSet {
   496  		stateSet[id] = store.NewBuildState(result, files, nil)
   497  	}
   498  	return stateSet
   499  }
   500  
   501  type fakeClock struct {
   502  	now time.Time
   503  }
   504  
   505  func (c fakeClock) Now() time.Time { return c.now }
   506  
   507  type fakeKINDLoader struct {
   508  	loadCount int
   509  }
   510  
   511  func (kl *fakeKINDLoader) LoadToKIND(ctx context.Context, cluster *v1alpha1.Cluster, ref reference.NamedTagged) error {
   512  	kl.loadCount++
   513  	return nil
   514  }