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

     1  package build
     2  
     3  import (
     4  	"archive/tar"
     5  	"context"
     6  	"fmt"
     7  	"log"
     8  	"os/exec"
     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/docker/docker/api/types/container"
    17  	"github.com/stretchr/testify/assert"
    18  
    19  	"github.com/tilt-dev/clusterid"
    20  	wmcontainer "github.com/tilt-dev/tilt/internal/container"
    21  	"github.com/tilt-dev/tilt/internal/docker"
    22  	"github.com/tilt-dev/tilt/internal/dockerfile"
    23  	"github.com/tilt-dev/tilt/internal/k8s"
    24  	"github.com/tilt-dev/tilt/internal/testutils"
    25  	"github.com/tilt-dev/tilt/internal/testutils/tempdir"
    26  	"github.com/tilt-dev/tilt/pkg/model"
    27  )
    28  
    29  type dockerBuildFixture struct {
    30  	*tempdir.TempDirFixture
    31  	t            testing.TB
    32  	ctx          context.Context
    33  	dCli         *docker.Cli
    34  	fakeDocker   *docker.FakeClient
    35  	b            *DockerBuilder
    36  	registry     *exec.Cmd
    37  	reaper       ImageReaper
    38  	containerIDs []wmcontainer.ID
    39  	ps           *PipelineState
    40  }
    41  
    42  type fakeClock struct {
    43  	now time.Time
    44  }
    45  
    46  func (c fakeClock) Now() time.Time { return c.now }
    47  
    48  func newDockerBuildFixture(t testing.TB) *dockerBuildFixture {
    49  	ctx, _, _ := testutils.CtxAndAnalyticsForTest()
    50  	env := clusterid.ProductGKE
    51  
    52  	kCli := k8s.NewFakeK8sClient(t)
    53  	kCli.Runtime = wmcontainer.RuntimeDocker
    54  	dEnv := docker.ProvideClusterEnv(ctx, docker.RealClientCreator{}, "gke", env, kCli, k8s.FakeMinikube{})
    55  	dCli := docker.NewDockerClient(ctx, docker.Env(dEnv))
    56  	_, ok := dCli.(*docker.Cli)
    57  	// If it wasn't an actual Docker client, it's an exploding client
    58  	if !ok {
    59  		// Call the simplest interface function that returns the error which originally occurred in NewDockerClient()
    60  		t.Fatal(dCli.CheckConnected())
    61  	}
    62  	ps := NewPipelineState(ctx, 3, fakeClock{})
    63  
    64  	labels := dockerfile.Labels(map[dockerfile.Label]dockerfile.LabelValue{
    65  		TestImage: "1",
    66  	})
    67  	ret := &dockerBuildFixture{
    68  		TempDirFixture: tempdir.NewTempDirFixture(t),
    69  		t:              t,
    70  		ctx:            ctx,
    71  		dCli:           dCli.(*docker.Cli),
    72  		b:              NewDockerBuilder(dCli, labels),
    73  		reaper:         NewImageReaper(dCli),
    74  		ps:             ps,
    75  	}
    76  
    77  	t.Cleanup(ret.teardown)
    78  	return ret
    79  }
    80  
    81  func newFakeDockerBuildFixture(t testing.TB) *dockerBuildFixture {
    82  	ctx, _, _ := testutils.CtxAndAnalyticsForTest()
    83  	dCli := docker.NewFakeClient()
    84  	labels := dockerfile.Labels(map[dockerfile.Label]dockerfile.LabelValue{
    85  		TestImage: "1",
    86  	})
    87  
    88  	ps := NewPipelineState(ctx, 3, realClock{})
    89  
    90  	ret := &dockerBuildFixture{
    91  		TempDirFixture: tempdir.NewTempDirFixture(t),
    92  		t:              t,
    93  		ctx:            ctx,
    94  		fakeDocker:     dCli,
    95  		b:              NewDockerBuilder(dCli, labels),
    96  		reaper:         NewImageReaper(dCli),
    97  		ps:             ps,
    98  	}
    99  
   100  	t.Cleanup(ret.teardown)
   101  	return ret
   102  }
   103  
   104  func (f *dockerBuildFixture) teardown() {
   105  	for _, cID := range f.containerIDs {
   106  		// ignore failures
   107  		_ = f.dCli.ContainerRemove(f.ctx, string(cID), types.ContainerRemoveOptions{
   108  			Force: true,
   109  		})
   110  	}
   111  
   112  	// ignore failures
   113  	_ = f.reaper.RemoveTiltImages(f.ctx, time.Now(), true /*force*/, FilterByLabel(TestImage))
   114  
   115  	if f.registry != nil && f.registry.Process != nil {
   116  		go func() {
   117  			err := f.registry.Process.Kill()
   118  			if err != nil {
   119  				log.Printf("killing the registry failed: %v\n", err)
   120  			}
   121  		}()
   122  
   123  		// ignore the error. we expect it to be killed
   124  		_ = f.registry.Wait()
   125  
   126  		_ = exec.Command("docker", "kill", "tilt-registry").Run()
   127  		_ = exec.Command("docker", "rm", "tilt-registry").Run()
   128  	}
   129  }
   130  
   131  func (f *dockerBuildFixture) getNameFromTest() wmcontainer.RefSet {
   132  	x := fmt.Sprintf("windmill.build/%s", strings.ToLower(f.t.Name()))
   133  	sel := wmcontainer.MustParseSelector(x)
   134  	return wmcontainer.MustSimpleRefSet(sel)
   135  }
   136  
   137  type expectedFile = testutils.ExpectedFile
   138  
   139  func (f *dockerBuildFixture) assertImageHasLabels(ref reference.Named, expected map[string]string) {
   140  	inspect, _, err := f.dCli.ImageInspectWithRaw(f.ctx, ref.String())
   141  	if err != nil {
   142  		f.t.Fatalf("error inspecting image %s: %v", ref.String(), err)
   143  	}
   144  
   145  	if inspect.Config == nil {
   146  		f.t.Fatalf("'inspect' result for image %s has nil config", ref.String())
   147  	}
   148  
   149  	actual := inspect.Config.Labels
   150  	for k, expectV := range expected {
   151  		actualV, ok := actual[k]
   152  		if assert.True(f.t, ok, "key %q not found in actual labels: %v", k, actual) {
   153  			assert.Equal(f.t, expectV, actualV, "actual label (%s = %s) did not match expected (%s = %s)",
   154  				k, actualV, k, expectV)
   155  		}
   156  	}
   157  
   158  }
   159  
   160  func (f *dockerBuildFixture) assertFilesInImage(ref reference.NamedTagged, expectedFiles []expectedFile) {
   161  	cID := f.startContainer(f.ctx, containerConfigRunCmd(ref, model.Cmd{}))
   162  	f.assertFilesInContainer(f.ctx, cID, expectedFiles)
   163  }
   164  
   165  func (f *dockerBuildFixture) assertFilesInContainer(
   166  	ctx context.Context, cID wmcontainer.ID, expectedFiles []expectedFile) {
   167  	for _, expectedFile := range expectedFiles {
   168  		reader, _, err := f.dCli.CopyFromContainer(ctx, cID.String(), expectedFile.Path)
   169  		if expectedFile.Missing {
   170  			if err == nil {
   171  				f.t.Errorf("Expected path %q to not exist", expectedFile.Path)
   172  			} else if !strings.Contains(err.Error(), "No such container:path") && !strings.Contains(err.Error(), "Could not find the file") {
   173  				f.t.Errorf("Expected path %q to not exist, but got a different error: %v", expectedFile.Path, err)
   174  			}
   175  
   176  			continue
   177  		}
   178  
   179  		if err != nil {
   180  			f.t.Fatal(err)
   181  		}
   182  
   183  		// When you copy a single file out of a container, you get
   184  		// back a tarball with 1 entry, the file basename.
   185  		adjustedFile := expectedFile
   186  		adjustedFile.Path = filepath.Base(adjustedFile.Path)
   187  		testutils.AssertFileInTar(f.t, tar.NewReader(reader), adjustedFile)
   188  	}
   189  }
   190  
   191  // startContainer starts a container from the given config
   192  func (f *dockerBuildFixture) startContainer(ctx context.Context, config *container.Config) wmcontainer.ID {
   193  	resp, err := f.dCli.ContainerCreate(ctx, config, nil, nil, nil, "")
   194  	if err != nil {
   195  		f.t.Fatalf("startContainer: %v", err)
   196  	}
   197  	cID := resp.ID
   198  
   199  	err = f.dCli.ContainerStart(ctx, cID, types.ContainerStartOptions{})
   200  	if err != nil {
   201  		f.t.Fatalf("startContainer: %v", err)
   202  	}
   203  
   204  	result := wmcontainer.ID(cID)
   205  	f.containerIDs = append(f.containerIDs, result)
   206  	return result
   207  }
   208  
   209  // Get a container config to run a container with a given command instead of
   210  // the existing entrypoint. If cmd is nil, we run nothing.
   211  func containerConfigRunCmd(imgRef reference.NamedTagged, cmd model.Cmd) *container.Config {
   212  	config := containerConfig(imgRef)
   213  
   214  	// In Docker, both the Entrypoint and the Cmd are used to determine what
   215  	// process the container runtime uses, where Entrypoint takes precedence over
   216  	// command. We set both here to ensure that we don't get weird results due
   217  	// to inheritance.
   218  	//
   219  	// If cmd is nil, we use a fake cmd that does nothing.
   220  	//
   221  	// https://github.com/opencontainers/image-spec/blob/master/config.md#properties
   222  	if cmd.Empty() {
   223  		config.Cmd = model.ToUnixCmd("# NOTE(nick): a fake cmd").Argv
   224  	} else {
   225  		config.Cmd = cmd.Argv
   226  	}
   227  	config.Entrypoint = []string{}
   228  	return config
   229  }
   230  
   231  // Get a container config to run a container as-is.
   232  func containerConfig(imgRef reference.NamedTagged) *container.Config {
   233  	return &container.Config{Image: imgRef.String()}
   234  }