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

     1  package docker
     2  
     3  import (
     4  	"bytes"
     5  	"context"
     6  	"fmt"
     7  	"io"
     8  	"sort"
     9  	"strings"
    10  	"time"
    11  	"unicode"
    12  
    13  	"github.com/docker/go-units"
    14  	"github.com/opencontainers/go-digest"
    15  	"github.com/pkg/errors"
    16  	"golang.org/x/sync/errgroup"
    17  
    18  	"github.com/distribution/reference"
    19  	"github.com/docker/docker/api/types"
    20  	apicontainer "github.com/docker/docker/api/types/container"
    21  	"github.com/docker/docker/api/types/filters"
    22  
    23  	"github.com/tilt-dev/tilt/internal/container"
    24  	"github.com/tilt-dev/tilt/pkg/model"
    25  )
    26  
    27  const ExampleBuildSHA1 = "sha256:11cd0b38bc3ceb958ffb2f9bd70be3fb317ce7d255c8a4c3f4af30e298aa1aab"
    28  
    29  const ExampleBuildOutput1 = `{"stream":"Run 1/1 : FROM alpine"}
    30  	{"stream":"\n"}
    31  	{"stream":" ---\u003e 11cd0b38bc3c\n"}
    32  	{"aux":{"ID":"sha256:11cd0b38bc3ceb958ffb2f9bd70be3fb317ce7d255c8a4c3f4af30e298aa1aab"}}
    33  	{"stream":"Successfully built 11cd0b38bc3c\n"}
    34  	{"stream":"Successfully tagged hi:latest\n"}
    35  `
    36  const ExampleBuildOutputV1_23 = `{"stream":"Run 1/1 : FROM alpine"}
    37  	{"stream":"\n"}
    38  	{"stream":" ---\u003e 11cd0b38bc3c\n"}
    39  	{"stream":"Successfully built 11cd0b38bc3c\n"}
    40  	{"stream":"Successfully tagged hi:latest\n"}
    41  `
    42  
    43  // same as ExampleBuildOutput1 but with a different digest
    44  const ExampleBuildOutput2 = `{"stream":"Run 1/1 : FROM alpine"}
    45  	{"stream":"\n"}
    46  	{"stream":" ---\u003e 20372c132963\n"}
    47  	{"aux":{"ID":"sha256:20372c132963eb958ffb2f9bd70be3fb317ce7d255c8a4c3f4af20372c132963"}}
    48  	{"stream":"Successfully built 20372c132963\n"}
    49  	{"stream":"Successfully tagged hi:latest\n"}
    50  `
    51  
    52  const ExamplePushSHA1 = "sha256:cc5f4c463f81c55183d8d737ba2f0d30b3e6f3670dbe2da68f0aac168e93fbb1"
    53  
    54  var ExamplePushOutput1 = `{"status":"The push refers to repository [localhost:5005/myimage]"}
    55  	{"status":"Preparing","progressDetail":{},"id":"2a88b569da78"}
    56  	{"status":"Preparing","progressDetail":{},"id":"73046094a9b8"}
    57  	{"status":"Pushing","progressDetail":{"current":512,"total":41},"progress":"[==================================================\u003e]     512B","id":"2a88b569da78"}
    58  	{"status":"Pushing","progressDetail":{"current":68608,"total":4413370},"progress":"[\u003e                                                  ]  68.61kB/4.413MB","id":"73046094a9b8"}
    59  	{"status":"Pushing","progressDetail":{"current":5120,"total":41},"progress":"[==================================================\u003e]   5.12kB","id":"2a88b569da78"}
    60  	{"status":"Pushed","progressDetail":{},"id":"2a88b569da78"}
    61  	{"status":"Pushing","progressDetail":{"current":1547776,"total":4413370},"progress":"[=================\u003e                                 ]  1.548MB/4.413MB","id":"73046094a9b8"}
    62  	{"status":"Pushing","progressDetail":{"current":3247616,"total":4413370},"progress":"[====================================\u003e              ]  3.248MB/4.413MB","id":"73046094a9b8"}
    63  	{"status":"Pushing","progressDetail":{"current":4672000,"total":4413370},"progress":"[==================================================\u003e]  4.672MB","id":"73046094a9b8"}
    64  	{"status":"Pushed","progressDetail":{},"id":"73046094a9b8"}
    65  	{"status":"tilt-11cd0b38bc3ceb95: digest: sha256:cc5f4c463f81c55183d8d737ba2f0d30b3e6f3670dbe2da68f0aac168e93fbb1 size: 735"}
    66  	{"progressDetail":{},"aux":{"Tag":"tilt-11cd0b38bc3ceb95","Digest":"sha256:cc5f4c463f81c55183d8d737ba2f0d30b3e6f3670dbe2da68f0aac168e93fbb1","Size":735}}`
    67  
    68  const (
    69  	TestPod       = "test_pod"
    70  	TestContainer = "test_container"
    71  )
    72  
    73  var DefaultContainerListOutput = map[string][]types.Container{
    74  	TestPod: []types.Container{
    75  		types.Container{ID: TestContainer, ImageID: ExampleBuildSHA1, Command: "./stuff"},
    76  	},
    77  	"two-containers": []types.Container{
    78  		types.Container{ID: "not a match", ImageID: ExamplePushSHA1, Command: "/pause"},
    79  		types.Container{ID: "the right container", ImageID: ExampleBuildSHA1, Command: "./stuff"},
    80  	},
    81  }
    82  
    83  type ExecCall struct {
    84  	Container string
    85  	Cmd       model.Cmd
    86  }
    87  
    88  type FakeClient struct {
    89  	FakeEnv Env
    90  
    91  	PushCount   int
    92  	PushImage   string
    93  	PushOptions types.ImagePushOptions
    94  	PushOutput  string
    95  
    96  	BuildCount        int
    97  	BuildOptions      BuildOptions
    98  	BuildContext      *bytes.Buffer
    99  	BuildOutput       string
   100  	BuildErrorToThrow error // next call to Build will throw this err (after which we clear the error)
   101  
   102  	ImageListCount int
   103  	ImageListOpts  []types.ImageListOptions
   104  
   105  	TagCount  int
   106  	TagSource string
   107  	TagTarget string
   108  
   109  	ContainerListOutput map[string][]types.Container
   110  
   111  	CopyCount     int
   112  	CopyContainer string
   113  	CopyContent   io.Reader
   114  
   115  	ExecCalls         []ExecCall
   116  	ExecErrorsToThrow []error // next call to exec will throw ExecError[0] (which we then pop)
   117  
   118  	RestartsByContainer map[string]int
   119  	RemovedImageIDs     []string
   120  
   121  	// Images returned by ImageInspect.
   122  	Images map[string]types.ImageInspect
   123  
   124  	// Containers returned by ContainerInspect
   125  	Containers        map[string]types.ContainerState
   126  	ContainerLogChans map[string]<-chan string
   127  
   128  	// If true, ImageInspectWithRaw will always return an ImageInspect,
   129  	// even if one hasn't been explicitly pre-loaded.
   130  	ImageAlwaysExists bool
   131  
   132  	Orchestrator      model.Orchestrator
   133  	CheckConnectedErr error
   134  
   135  	ThrowNewVersionError   bool
   136  	BuildCachePruneErr     error
   137  	BuildCachePruneOpts    types.BuildCachePruneOptions
   138  	BuildCachesPruned      []string
   139  	ContainersPruneErr     error
   140  	ContainersPruneFilters filters.Args
   141  	ContainersPruned       []string
   142  }
   143  
   144  var _ Client = &FakeClient{}
   145  
   146  func NewFakeClient() *FakeClient {
   147  	return &FakeClient{
   148  		PushOutput:          ExamplePushOutput1,
   149  		BuildOutput:         ExampleBuildOutput1,
   150  		ContainerListOutput: make(map[string][]types.Container),
   151  		RestartsByContainer: make(map[string]int),
   152  		Images:              make(map[string]types.ImageInspect),
   153  		Containers:          make(map[string]types.ContainerState),
   154  		ContainerLogChans:   make(map[string]<-chan string),
   155  	}
   156  }
   157  
   158  func (c *FakeClient) SetOrchestrator(orc model.Orchestrator) {
   159  	c.Orchestrator = orc
   160  }
   161  func (c *FakeClient) ForOrchestrator(orc model.Orchestrator) Client {
   162  	return c
   163  }
   164  func (c *FakeClient) CheckConnected() error {
   165  	return c.CheckConnectedErr
   166  }
   167  func (c *FakeClient) Env() Env {
   168  	return c.FakeEnv
   169  }
   170  func (c *FakeClient) BuilderVersion(ctx context.Context) (types.BuilderVersion, error) {
   171  	return types.BuilderV1, nil
   172  }
   173  func (c *FakeClient) ServerVersion(ctx context.Context) (types.Version, error) {
   174  	return types.Version{
   175  		Arch:    "amd64",
   176  		Version: "20.10.11",
   177  	}, nil
   178  }
   179  
   180  func (c *FakeClient) SetExecError(err error) {
   181  	c.ExecErrorsToThrow = []error{err}
   182  }
   183  
   184  func (c *FakeClient) ContainerLogs(ctx context.Context, containerID string, options types.ContainerLogsOptions) (io.ReadCloser, error) {
   185  	output := c.ContainerLogChans[containerID]
   186  	reader, writer := io.Pipe()
   187  	go func() {
   188  		if ctx.Err() != nil {
   189  			return
   190  		}
   191  
   192  		done := false
   193  		for !done {
   194  			select {
   195  			case <-ctx.Done():
   196  				done = true
   197  			case s, ok := <-output:
   198  				if !ok {
   199  					done = true
   200  				} else {
   201  					logLine := fmt.Sprintf("%s\n",
   202  						strings.TrimRightFunc(s, unicode.IsSpace))
   203  					_, _ = writer.Write([]byte(logLine))
   204  				}
   205  			}
   206  		}
   207  	}()
   208  	return reader, nil
   209  }
   210  
   211  func (c *FakeClient) ContainerInspect(ctx context.Context, containerID string) (types.ContainerJSON, error) {
   212  	container, ok := c.Containers[containerID]
   213  	if ok {
   214  		return types.ContainerJSON{
   215  			Config: &apicontainer.Config{Tty: true},
   216  			ContainerJSONBase: &types.ContainerJSONBase{
   217  				ID:    containerID,
   218  				State: &container,
   219  			},
   220  		}, nil
   221  	}
   222  	state := NewRunningContainerState()
   223  	return types.ContainerJSON{
   224  		Config: &apicontainer.Config{Tty: true},
   225  		ContainerJSONBase: &types.ContainerJSONBase{
   226  			ID:    containerID,
   227  			State: &state,
   228  		},
   229  	}, nil
   230  }
   231  
   232  func (c *FakeClient) SetContainerListOutput(output map[string][]types.Container) {
   233  	c.ContainerListOutput = output
   234  }
   235  
   236  func (c *FakeClient) SetDefaultContainerListOutput() {
   237  	c.SetContainerListOutput(DefaultContainerListOutput)
   238  }
   239  
   240  func (c *FakeClient) ContainerList(ctx context.Context, options types.ContainerListOptions) ([]types.Container, error) {
   241  	nameFilter := options.Filters.Get("name")
   242  	if len(nameFilter) != 1 {
   243  		return nil, fmt.Errorf("expected one filter for 'name', got: %v", nameFilter)
   244  	}
   245  
   246  	if len(c.ContainerListOutput) == 0 {
   247  		return nil, fmt.Errorf("FakeClient ContainerListOutput not set (use `SetContainerListOutput`)")
   248  	}
   249  	res := c.ContainerListOutput[nameFilter[0]]
   250  
   251  	// unset containerListOutput
   252  	c.ContainerListOutput = nil
   253  
   254  	return res, nil
   255  }
   256  
   257  func (c *FakeClient) ContainerRestartNoWait(ctx context.Context, containerID string) error {
   258  	c.RestartsByContainer[containerID]++
   259  	return nil
   260  }
   261  
   262  func (c *FakeClient) Run(ctx context.Context, opts RunConfig) (RunResult, error) {
   263  	return RunResult{}, nil
   264  }
   265  
   266  func (c *FakeClient) ExecInContainer(ctx context.Context, cID container.ID, cmd model.Cmd, in io.Reader, out io.Writer) error {
   267  	if cmd.Argv[0] == "tar" {
   268  		c.CopyCount++
   269  		c.CopyContainer = string(cID)
   270  		c.CopyContent = in
   271  		return nil
   272  	}
   273  
   274  	execCall := ExecCall{
   275  		Container: cID.String(),
   276  		Cmd:       cmd,
   277  	}
   278  	c.ExecCalls = append(c.ExecCalls, execCall)
   279  
   280  	// If we're supposed to throw an error on this call, throw it (and pop from
   281  	// the list of ErrorsToThrow)
   282  	var err error
   283  	if len(c.ExecErrorsToThrow) > 0 {
   284  		err = c.ExecErrorsToThrow[0]
   285  		c.ExecErrorsToThrow = append([]error{}, c.ExecErrorsToThrow[1:]...)
   286  	}
   287  
   288  	return err
   289  }
   290  
   291  func (c *FakeClient) ImagePull(_ context.Context, ref reference.Named) (reference.Canonical, error) {
   292  	// fake digest is the reference itself hashed
   293  	// i.e. docker.io/library/_/nginx -> sha256sum(docker.io/library/_/nginx) -> 2ca21a92e8ee99f672764b7619a413019de5ffc7f06dbc7422d41eca17705802
   294  	return reference.WithDigest(ref, digest.FromString(ref.String()))
   295  }
   296  
   297  func (c *FakeClient) ImagePush(ctx context.Context, ref reference.NamedTagged) (io.ReadCloser, error) {
   298  	c.PushCount++
   299  	c.PushImage = ref.String()
   300  	return NewFakeDockerResponse(c.PushOutput), nil
   301  }
   302  
   303  func (c *FakeClient) ImageBuild(ctx context.Context, g *errgroup.Group, buildContext io.Reader, options BuildOptions) (types.ImageBuildResponse, error) {
   304  	c.BuildCount++
   305  	c.BuildOptions = options
   306  
   307  	data, err := io.ReadAll(buildContext)
   308  	if err != nil {
   309  		return types.ImageBuildResponse{}, errors.Wrap(err, "ImageBuild")
   310  	}
   311  
   312  	c.BuildContext = bytes.NewBuffer(data)
   313  
   314  	// If we're supposed to throw an error on this call, throw it (and reset ErrorToThrow)
   315  	err = c.BuildErrorToThrow
   316  	if err != nil {
   317  		c.BuildErrorToThrow = nil
   318  		return types.ImageBuildResponse{}, err
   319  	}
   320  
   321  	return types.ImageBuildResponse{Body: NewFakeDockerResponse(c.BuildOutput)}, nil
   322  }
   323  
   324  func (c *FakeClient) ImageTag(ctx context.Context, source, target string) error {
   325  	c.TagCount++
   326  	c.TagSource = source
   327  	c.TagTarget = target
   328  	return nil
   329  }
   330  
   331  func (c *FakeClient) ImageInspectWithRaw(ctx context.Context, imageID string) (types.ImageInspect, []byte, error) {
   332  	result, ok := c.Images[imageID]
   333  	if ok {
   334  		return result, nil, nil
   335  	}
   336  
   337  	if c.ImageAlwaysExists {
   338  		return types.ImageInspect{}, nil, nil
   339  	}
   340  
   341  	return types.ImageInspect{}, nil, newNotFoundErrorf("fakeClient.Images key: %s", imageID)
   342  }
   343  
   344  func (c *FakeClient) ImageList(ctx context.Context, options types.ImageListOptions) ([]types.ImageSummary, error) {
   345  	c.ImageListOpts = append(c.ImageListOpts, options)
   346  	summaries := make([]types.ImageSummary, c.ImageListCount)
   347  	for i := range summaries {
   348  		summaries[i] = types.ImageSummary{
   349  			ID:      fmt.Sprintf("build-id-%d", i),
   350  			Created: time.Now().Add(-time.Second).Unix(),
   351  		}
   352  	}
   353  	return summaries, nil
   354  }
   355  
   356  func (c *FakeClient) ImageRemove(ctx context.Context, imageID string, options types.ImageRemoveOptions) ([]types.ImageDeleteResponseItem, error) {
   357  	c.RemovedImageIDs = append(c.RemovedImageIDs, imageID)
   358  	sort.Strings(c.RemovedImageIDs)
   359  	return []types.ImageDeleteResponseItem{
   360  		types.ImageDeleteResponseItem{Deleted: imageID},
   361  	}, nil
   362  }
   363  
   364  func (c *FakeClient) NewVersionError(apiRequired, feature string) error {
   365  	if c.ThrowNewVersionError {
   366  		c.ThrowNewVersionError = false
   367  		return c.VersionError(apiRequired, feature)
   368  	}
   369  	return nil
   370  }
   371  
   372  func (c *FakeClient) VersionError(apiRequired, feature string) error {
   373  	return fmt.Errorf("%q requires API version %s, but the Docker daemon API version is... something else", feature, apiRequired)
   374  }
   375  
   376  func (c *FakeClient) BuildCachePrune(ctx context.Context, opts types.BuildCachePruneOptions) (*types.BuildCachePruneReport, error) {
   377  	if err := c.BuildCachePruneErr; err != nil {
   378  		c.BuildCachePruneErr = nil
   379  		return nil, err
   380  	}
   381  
   382  	c.BuildCachePruneOpts = types.BuildCachePruneOptions{
   383  		All:         opts.All,
   384  		KeepStorage: opts.KeepStorage,
   385  		Filters:     opts.Filters.Clone(),
   386  	}
   387  	report := &types.BuildCachePruneReport{
   388  		CachesDeleted:  c.BuildCachesPruned,
   389  		SpaceReclaimed: uint64(units.MB * len(c.BuildCachesPruned)), // 1MB per cache pruned
   390  	}
   391  	c.BuildCachesPruned = nil
   392  	return report, nil
   393  }
   394  
   395  func (c *FakeClient) ContainersPrune(ctx context.Context, pruneFilters filters.Args) (types.ContainersPruneReport, error) {
   396  	if err := c.ContainersPruneErr; err != nil {
   397  		c.ContainersPruneErr = nil
   398  		return types.ContainersPruneReport{}, err
   399  	}
   400  
   401  	c.ContainersPruneFilters = pruneFilters.Clone()
   402  	report := types.ContainersPruneReport{
   403  		ContainersDeleted: c.ContainersPruned,
   404  		SpaceReclaimed:    uint64(units.MB * len(c.ContainersPruned)), // 1MB per container pruned
   405  	}
   406  	c.ContainersPruned = nil
   407  	return report, nil
   408  }
   409  
   410  var _ Client = &FakeClient{}
   411  
   412  type fakeDockerResponse struct {
   413  	*bytes.Buffer
   414  }
   415  
   416  func NewFakeDockerResponse(contents string) fakeDockerResponse {
   417  	return fakeDockerResponse{Buffer: bytes.NewBufferString(contents)}
   418  }
   419  
   420  func (r fakeDockerResponse) Close() error { return nil }
   421  
   422  var _ io.ReadCloser = fakeDockerResponse{}
   423  
   424  type notFoundError struct {
   425  	details string
   426  }
   427  
   428  func newNotFoundErrorf(s string, a ...interface{}) notFoundError {
   429  	return notFoundError{details: fmt.Sprintf(s, a...)}
   430  }
   431  
   432  func (e notFoundError) NotFound() bool {
   433  	return true
   434  }
   435  
   436  func (e notFoundError) Error() string {
   437  	return fmt.Sprintf("fake docker client error: object not found (%s)", e.details)
   438  }