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

     1  package dockerprune
     2  
     3  import (
     4  	"bytes"
     5  	"context"
     6  	"fmt"
     7  	"testing"
     8  	"time"
     9  
    10  	"github.com/distribution/reference"
    11  	"github.com/docker/docker/api/types"
    12  	"github.com/docker/docker/api/types/filters"
    13  	"github.com/docker/go-units"
    14  	"github.com/stretchr/testify/assert"
    15  	"github.com/stretchr/testify/require"
    16  
    17  	"github.com/tilt-dev/tilt/internal/container"
    18  	"github.com/tilt-dev/tilt/internal/docker"
    19  	"github.com/tilt-dev/tilt/internal/store"
    20  	"github.com/tilt-dev/tilt/internal/testutils"
    21  	"github.com/tilt-dev/tilt/pkg/apis/core/v1alpha1"
    22  	"github.com/tilt-dev/tilt/pkg/model"
    23  )
    24  
    25  var (
    26  	cachesPruned     = []string{"cacheA", "cacheB", "cacheC"}
    27  	containersPruned = []string{"containerA", "containerB", "containerC"}
    28  	numImages        = 3
    29  	maxAge           = 11 * time.Hour
    30  	refSel           = container.MustParseSelector("some-ref")
    31  	keep0            = 0
    32  )
    33  
    34  var buildHistory = []model.BuildRecord{
    35  	model.BuildRecord{StartTime: time.Now().Add(-24 * time.Hour)},
    36  }
    37  
    38  func twoHrsAgo() time.Time {
    39  	return time.Now().Add(-2 * time.Hour)
    40  }
    41  
    42  func TestPruneFilters(t *testing.T) {
    43  	f, imgSelectors := newFixture(t).withPruneOutput(cachesPruned, containersPruned, numImages)
    44  	err := f.dp.prune(f.ctx, maxAge, keep0, imgSelectors)
    45  	require.NoError(t, err)
    46  
    47  	expectedFilters := filters.NewArgs(
    48  		filters.Arg("label", gcEnabledSelector),
    49  		filters.Arg("until", maxAge.String()),
    50  	)
    51  	expectedImageFilters := filters.NewArgs(
    52  		filters.Arg("label", gcEnabledSelector),
    53  	)
    54  
    55  	assert.Equal(t, expectedFilters, f.dCli.BuildCachePruneOpts.Filters, "build cache prune filters")
    56  	assert.Equal(t, expectedFilters, f.dCli.ContainersPruneFilters, "container prune filters")
    57  	if assert.Len(t, f.dCli.ImageListOpts, 1, "expect exactly one call to ImageList") {
    58  		assert.Equal(t, expectedImageFilters, f.dCli.ImageListOpts[0].Filters, "image list filters")
    59  	}
    60  }
    61  
    62  func TestPruneOutput(t *testing.T) {
    63  	f, imgSelectors := newFixture(t).withPruneOutput(cachesPruned, containersPruned, numImages)
    64  	err := f.dp.prune(f.ctx, maxAge, keep0, imgSelectors)
    65  	require.NoError(t, err)
    66  
    67  	logs := f.logs.String()
    68  	assert.Contains(t, logs, "[Docker Prune] removed 3 containers, reclaimed 3MB")
    69  	assert.Contains(t, logs, "- containerC")
    70  	assert.Contains(t, logs, "[Docker Prune] removed 3 images, reclaimed 6MB")
    71  	assert.Contains(t, logs, "- deleted: build-id-2")
    72  	assert.Contains(t, logs, "[Docker Prune] removed 3 caches, reclaimed 3MB")
    73  	assert.Contains(t, logs, "- cacheC")
    74  }
    75  
    76  func TestPruneVersionTooLow(t *testing.T) {
    77  	f, imgSelectors := newFixture(t).withPruneOutput(cachesPruned, containersPruned, numImages)
    78  	f.dCli.ThrowNewVersionError = true
    79  	err := f.dp.prune(f.ctx, maxAge, keep0, imgSelectors)
    80  	require.NoError(t, err) // should log failure but not throw error
    81  
    82  	logs := f.logs.String()
    83  	assert.Contains(t, logs, "skipping Docker prune")
    84  
    85  	// Should NOT have called any of the prune funcs
    86  	assert.Empty(t, f.dCli.BuildCachePruneOpts)
    87  	assert.Empty(t, f.dCli.ContainersPruneFilters)
    88  	assert.Empty(t, f.dCli.ImageListOpts)
    89  	assert.Empty(t, f.dCli.RemovedImageIDs)
    90  }
    91  
    92  func TestPruneSkipCachePruneIfVersionTooLow(t *testing.T) {
    93  	f, imgSelectors := newFixture(t).withPruneOutput(cachesPruned, containersPruned, numImages)
    94  	f.dCli.BuildCachePruneErr = f.dCli.VersionError("1.2.3", "build prune")
    95  	err := f.dp.prune(f.ctx, maxAge, keep0, imgSelectors)
    96  	require.NoError(t, err) // should log failure but not throw error
    97  
    98  	logs := f.logs.String()
    99  	assert.Contains(t, logs, "skipping build cache prune")
   100  
   101  	// Should have called previous prune funcs as normal
   102  	assert.NotEmpty(t, f.dCli.ContainersPruneFilters)
   103  	assert.NotEmpty(t, f.dCli.ImageListOpts)
   104  	assert.NotEmpty(t, f.dCli.RemovedImageIDs)
   105  }
   106  
   107  func TestPruneReturnsCachePruneError(t *testing.T) {
   108  	f, imgSelectors := newFixture(t).withPruneOutput(cachesPruned, containersPruned, numImages)
   109  	f.dCli.BuildCachePruneErr = fmt.Errorf("this is a real error, NOT an API version error")
   110  	err := f.dp.prune(f.ctx, maxAge, keep0, imgSelectors)
   111  	require.NotNil(t, err) // For all errors besides API version error, expect them to return
   112  	assert.Contains(t, err.Error(), "this is a real error")
   113  
   114  	logs := f.logs.String()
   115  	assert.NotContains(t, logs, "skipping build cache prune")
   116  
   117  	// Should have called previous prune funcs as normal
   118  	assert.NotEmpty(t, f.dCli.ContainersPruneFilters)
   119  	assert.NotEmpty(t, f.dCli.ImageListOpts)
   120  	assert.NotEmpty(t, f.dCli.RemovedImageIDs)
   121  }
   122  
   123  func TestDeleteOldImages(t *testing.T) {
   124  	f := newFixture(t)
   125  	maxAge := 3 * time.Hour
   126  	_, _ = f.withImageInspect(0, 25, time.Hour)       // young enough, won't be pruned
   127  	id, ref := f.withImageInspect(1, 50, 4*time.Hour) // older than max age, will be pruned
   128  	_, _ = f.withImageInspect(2, 75, 6*time.Hour)     // older than max age but doesn't match passed ref selectors
   129  	report, err := f.dp.deleteOldImages(f.ctx, maxAge, keep0, []container.RefSelector{container.NameSelector(ref)})
   130  	require.NoError(t, err)
   131  
   132  	assert.Len(t, report.ImagesDeleted, 1, "expected exactly one deleted image")
   133  	assert.Equal(t, 50, int(report.SpaceReclaimed), "expected space reclaimed")
   134  
   135  	expectedDeleted := []string{id}
   136  	assert.Equal(t, expectedDeleted, f.dCli.RemovedImageIDs)
   137  
   138  	expectedFilters := filters.NewArgs(filters.Arg("label", gcEnabledSelector))
   139  	if assert.Len(t, f.dCli.ImageListOpts, 1, "expected exactly one call to ImageList") {
   140  		assert.Equal(t, expectedFilters, f.dCli.ImageListOpts[0].Filters,
   141  			"expected ImageList to called with label=builtby:tilt filter")
   142  	}
   143  }
   144  
   145  func TestKeepRecentImages(t *testing.T) {
   146  	f := newFixture(t)
   147  	maxAge := time.Minute
   148  	_, ref1 := f.withImageInspect(0, 10, time.Hour)
   149  	idOldest, ref2 := f.withImageInspect(0, 100, 4*time.Hour)
   150  	_, ref3 := f.withImageInspect(0, 1000, 3*time.Hour)
   151  	selectors := []container.RefSelector{
   152  		container.NameSelector(ref1),
   153  		container.NameSelector(ref2),
   154  		container.NameSelector(ref3),
   155  	}
   156  
   157  	keep4 := 4
   158  	report, err := f.dp.deleteOldImages(f.ctx, maxAge, keep4, selectors)
   159  	require.NoError(t, err)
   160  	assert.Len(t, report.ImagesDeleted, 0)
   161  
   162  	keep2 := 2
   163  	report, err = f.dp.deleteOldImages(f.ctx, maxAge, keep2, selectors)
   164  	require.NoError(t, err)
   165  	assert.Len(t, report.ImagesDeleted, 1)
   166  
   167  	// deletes the oldest image
   168  	expectedDeleted := []string{idOldest}
   169  	assert.Equal(t, expectedDeleted, f.dCli.RemovedImageIDs)
   170  }
   171  
   172  func TestKeepRecentImagesMultipleTags(t *testing.T) {
   173  	f := newFixture(t)
   174  	maxAge := time.Minute
   175  	_, refA1 := f.withImageInspect(0, 10, time.Hour)
   176  	idA2, refA2 := f.withImageInspect(0, 100, 2*time.Hour)
   177  	idA3, refA3 := f.withImageInspect(0, 1000, 3*time.Hour)
   178  	idB2, refB2 := f.withImageInspect(1, 100, 5*time.Hour)
   179  	_, refB1 := f.withImageInspect(1, 10, 4*time.Hour)
   180  	selectors := []container.RefSelector{
   181  		container.NameSelector(refA1),
   182  		container.NameSelector(refA2),
   183  		container.NameSelector(refA3),
   184  		container.NameSelector(refB1),
   185  		container.NameSelector(refB2),
   186  	}
   187  
   188  	keep4 := 4
   189  	report, err := f.dp.deleteOldImages(f.ctx, maxAge, keep4, selectors)
   190  	require.NoError(t, err)
   191  	assert.Len(t, report.ImagesDeleted, 0)
   192  
   193  	keep1 := 1
   194  	report, err = f.dp.deleteOldImages(f.ctx, maxAge, keep1, selectors)
   195  	require.NoError(t, err)
   196  	assert.Len(t, report.ImagesDeleted, 3)
   197  
   198  	// deletes the oldest images from each tag
   199  	expectedDeleted := []string{idA2, idA3, idB2}
   200  	assert.Equal(t, expectedDeleted, f.dCli.RemovedImageIDs)
   201  }
   202  
   203  func TestDeleteOldImagesDontRemoveImageWithMultipleTags(t *testing.T) {
   204  	f := newFixture(t)
   205  	maxAge := 3 * time.Hour
   206  	id, ref := f.withImageInspect(0, 50, 4*time.Hour)
   207  	inspect := f.dCli.Images[id]
   208  	inspect.RepoTags = append(f.dCli.Images[id].RepoTags, "some-additional-tag")
   209  	f.dCli.Images[id] = inspect
   210  
   211  	report, err := f.dp.deleteOldImages(f.ctx, maxAge, keep0, []container.RefSelector{container.NameSelector(ref)})
   212  	require.NoError(t, err) // error is silent
   213  
   214  	assert.Len(t, report.ImagesDeleted, 0, "expected no deleted images")
   215  	assert.Equal(t, 0, int(report.SpaceReclaimed), "expected space reclaimed")
   216  
   217  	assert.Contains(t, f.logs.String(), "`docker image remove --force` required to remove an image with multiple tags")
   218  }
   219  
   220  func TestDockerPrunerSinceNBuilds(t *testing.T) {
   221  	f := newFixture(t)
   222  	f.withDockerManifestAlreadyBuilt()
   223  	f.withBuildCount(11)
   224  	f.withDockerPruneSettings(true, 0, 5, 0)
   225  	f.dp.lastPruneBuildCount = 5
   226  	f.dp.lastPruneTime = twoHrsAgo()
   227  
   228  	_ = f.dp.OnChange(f.ctx, f.st, store.LegacyChangeSummary())
   229  
   230  	f.assertPrune()
   231  }
   232  
   233  func TestDockerPrunerNotEnoughBuilds(t *testing.T) {
   234  	f := newFixture(t)
   235  	f.withDockerManifestAlreadyBuilt()
   236  	f.withBuildCount(11)
   237  	f.withDockerPruneSettings(true, 0, 10, 0)
   238  	f.dp.lastPruneBuildCount = 5
   239  	f.dp.lastPruneTime = twoHrsAgo()
   240  
   241  	_ = f.dp.OnChange(f.ctx, f.st, store.LegacyChangeSummary())
   242  
   243  	f.assertNoPrune()
   244  }
   245  
   246  func TestDockerPrunerSinceInterval(t *testing.T) {
   247  	f := newFixture(t)
   248  	f.withDockerManifestAlreadyBuilt()
   249  	f.withDockerPruneSettings(true, 0, 0, 30*time.Minute)
   250  	f.dp.lastPruneTime = twoHrsAgo()
   251  
   252  	_ = f.dp.OnChange(f.ctx, f.st, store.LegacyChangeSummary())
   253  
   254  	f.assertPrune()
   255  }
   256  
   257  func TestDockerPrunerSinceDefaultInterval(t *testing.T) {
   258  	f := newFixture(t)
   259  	f.withDockerManifestAlreadyBuilt()
   260  	f.withDockerPruneSettings(true, 0, 0, 0)
   261  	f.dp.lastPruneTime = time.Now().Add(-1 * (model.DockerPruneDefaultInterval + time.Minute))
   262  
   263  	_ = f.dp.OnChange(f.ctx, f.st, store.LegacyChangeSummary())
   264  
   265  	f.assertPrune()
   266  }
   267  
   268  func TestDockerPrunerNotEnoughTimeElapsed(t *testing.T) {
   269  	f := newFixture(t)
   270  	f.withDockerManifestAlreadyBuilt()
   271  	f.withDockerPruneSettings(true, 0, 0, 3*time.Hour)
   272  	f.dp.lastPruneTime = twoHrsAgo()
   273  
   274  	_ = f.dp.OnChange(f.ctx, f.st, store.LegacyChangeSummary())
   275  
   276  	f.assertNoPrune()
   277  }
   278  
   279  func TestDockerPrunerSinceDefaultIntervalNotEnoughTime(t *testing.T) {
   280  	f := newFixture(t)
   281  	f.withDockerManifestAlreadyBuilt()
   282  	f.withDockerPruneSettings(true, 0, 0, 0)
   283  	f.dp.lastPruneTime = time.Now().Add(-1 * model.DockerPruneDefaultInterval).Add(20 * time.Minute)
   284  
   285  	_ = f.dp.OnChange(f.ctx, f.st, store.LegacyChangeSummary())
   286  
   287  	f.assertNoPrune()
   288  }
   289  
   290  func TestDockerPrunerFirstRun(t *testing.T) {
   291  	f := newFixture(t)
   292  	f.withDockerManifestAlreadyBuilt()
   293  	f.withBuildCount(5)
   294  	f.withDockerPruneSettings(true, 0, 10, 0)
   295  
   296  	_ = f.dp.OnChange(f.ctx, f.st, store.LegacyChangeSummary())
   297  
   298  	f.assertPrune()
   299  }
   300  
   301  func TestDockerPrunerFirstRunButNoCompletedBuilds(t *testing.T) {
   302  	f := newFixture(t)
   303  	f.withDockerManifestAlreadyBuilt()
   304  	f.withBuildCount(0)
   305  	f.withDockerPruneSettings(true, 0, 10, 0)
   306  
   307  	_ = f.dp.OnChange(f.ctx, f.st, store.LegacyChangeSummary())
   308  
   309  	f.assertNoPrune()
   310  }
   311  
   312  func TestDockerPrunerNoBuildManifests(t *testing.T) {
   313  	f := newFixture(t)
   314  	f.withK8sOnlyManifest()
   315  	f.withBuildCount(11)
   316  	f.withDockerPruneSettings(true, 0, 5, 0)
   317  
   318  	_ = f.dp.OnChange(f.ctx, f.st, store.LegacyChangeSummary())
   319  
   320  	f.assertNoPrune()
   321  }
   322  
   323  func TestDockerPrunerOnlyCustomBuildManifests(t *testing.T) {
   324  	f := newFixture(t)
   325  	f.withBuildCount(11)
   326  	f.withDockerPruneSettings(true, 0, 5, 0)
   327  
   328  	iTarget := model.MustNewImageTarget(refSel).WithBuildDetails(model.CustomBuild{})
   329  	m := model.Manifest{Name: "custom-build-manifest"}.WithImageTarget(iTarget)
   330  	f.withManifestTarget(store.NewManifestTarget(m), true)
   331  
   332  	_ = f.dp.OnChange(f.ctx, f.st, store.LegacyChangeSummary())
   333  
   334  	f.assertPrune()
   335  }
   336  
   337  func TestDockerPrunerDisabled(t *testing.T) {
   338  	f := newFixture(t)
   339  	f.withDockerManifestAlreadyBuilt()
   340  	f.withDockerPruneSettings(false, 0, 0, 0)
   341  
   342  	_ = f.dp.OnChange(f.ctx, f.st, store.LegacyChangeSummary())
   343  
   344  	f.assertNoPrune()
   345  }
   346  
   347  func TestDockerPrunerCurrentlyBuilding(t *testing.T) {
   348  	f := newFixture(t)
   349  	f.withDockerManifestAlreadyBuilt()
   350  	f.withCurrentlyBuilding("idk something")
   351  	f.withDockerPruneSettings(true, 0, 0, time.Hour)
   352  	f.dp.lastPruneTime = twoHrsAgo()
   353  
   354  	_ = f.dp.OnChange(f.ctx, f.st, store.LegacyChangeSummary())
   355  
   356  	f.assertNoPrune()
   357  }
   358  
   359  func TestDockerPrunerPendingBuild(t *testing.T) {
   360  	f := newFixture(t)
   361  	f.withDockerManifestUnbuilt() // manifest not yet built will be pending, so we should not prune
   362  	f.withDockerPruneSettings(true, 0, 0, time.Hour)
   363  	f.dp.lastPruneTime = twoHrsAgo()
   364  
   365  	_ = f.dp.OnChange(f.ctx, f.st, store.LegacyChangeSummary())
   366  
   367  	f.assertNoPrune()
   368  }
   369  
   370  func TestDockerPrunerMaxAgeFromSettings(t *testing.T) {
   371  	f := newFixture(t)
   372  	f.withDockerManifestAlreadyBuilt()
   373  	f.withBuildCount(5)
   374  	maxAge := time.Hour
   375  	f.withDockerPruneSettings(true, maxAge, 10, 0)
   376  
   377  	_ = f.dp.OnChange(f.ctx, f.st, store.LegacyChangeSummary())
   378  
   379  	f.assertPrune()
   380  	untilVals := f.dCli.ContainersPruneFilters.Get("until")
   381  	require.Len(t, untilVals, 1, "unexpected number of filters for \"until\"")
   382  	assert.Equal(t, untilVals[0], maxAge.String())
   383  }
   384  
   385  type dockerPruneFixture struct {
   386  	t    *testing.T
   387  	ctx  context.Context
   388  	logs *bytes.Buffer
   389  	st   *store.TestingStore
   390  
   391  	dCli *docker.FakeClient
   392  	dp   *DockerPruner
   393  }
   394  
   395  func newFixture(t *testing.T) *dockerPruneFixture {
   396  	logs := new(bytes.Buffer)
   397  	ctx, _, _ := testutils.ForkedCtxAndAnalyticsForTest(logs)
   398  	st := store.NewTestingStore()
   399  
   400  	dCli := docker.NewFakeClient()
   401  	dp := NewDockerPruner(dCli)
   402  
   403  	return &dockerPruneFixture{
   404  		t:    t,
   405  		ctx:  ctx,
   406  		logs: logs,
   407  		st:   st,
   408  		dCli: dCli,
   409  		dp:   dp,
   410  	}
   411  }
   412  
   413  func (dpf *dockerPruneFixture) withPruneOutput(caches, containers []string, numImages int) (*dockerPruneFixture, []container.RefSelector) {
   414  	dpf.dCli.BuildCachesPruned = caches
   415  	dpf.dCli.ContainersPruned = containers
   416  
   417  	selectors := make([]container.RefSelector, numImages)
   418  	for i := 0; i < numImages; i++ {
   419  		_, ref := dpf.withImageInspect(i, units.MB*(i+1), 48*time.Hour) // make each image 2 days old (def older than maxAge)
   420  		selectors[i] = container.NameSelector(ref)
   421  	}
   422  	return dpf, selectors
   423  }
   424  
   425  func (dpf *dockerPruneFixture) withImageInspect(i, size int, timeSinceLastTag time.Duration) (id string, ref reference.Named) {
   426  	tag := fmt.Sprintf("tag-%d", i)
   427  	id = fmt.Sprintf("build-id-%d", dpf.dCli.ImageListCount)
   428  	dpf.dCli.Images[id] = types.ImageInspect{
   429  		ID:       id,
   430  		RepoTags: []string{tag},
   431  		Size:     int64(size),
   432  		Metadata: types.ImageMetadata{
   433  			LastTagTime: time.Now().Add(-1 * timeSinceLastTag),
   434  		},
   435  	}
   436  	dpf.dCli.ImageListCount += 1
   437  	return id, container.MustParseNamed(tag)
   438  }
   439  
   440  func (dpf *dockerPruneFixture) withDockerManifestAlreadyBuilt() {
   441  	dpf.withDockerManifest(true)
   442  }
   443  
   444  func (dpf *dockerPruneFixture) withDockerManifestUnbuilt() {
   445  	dpf.withDockerManifest(false)
   446  }
   447  
   448  func (dpf *dockerPruneFixture) withDockerManifest(alreadyBuilt bool) {
   449  	iTarget := model.MustNewImageTarget(refSel).
   450  		WithBuildDetails(model.DockerBuild{})
   451  
   452  	m := model.Manifest{Name: "some-docker-manifest"}.
   453  		WithImageTarget(iTarget)
   454  
   455  	dpf.withManifestTarget(store.NewManifestTarget(m), alreadyBuilt)
   456  }
   457  
   458  func (dpf *dockerPruneFixture) withK8sOnlyManifest() {
   459  	m := model.Manifest{Name: "i'm-k8s-only"}.WithDeployTarget(model.K8sTarget{})
   460  	dpf.withManifestTarget(store.NewManifestTarget(m), true)
   461  }
   462  
   463  func (dpf *dockerPruneFixture) withManifestTarget(mt *store.ManifestTarget, alreadyBuilt bool) {
   464  	mt.State.DisableState = v1alpha1.DisableStateEnabled
   465  	if alreadyBuilt {
   466  		// spoof build history so we think this manifest has already been built (i.e. isn't pending)
   467  		mt.State.BuildHistory = buildHistory
   468  	}
   469  
   470  	store := dpf.st.LockMutableStateForTesting()
   471  	store.UpsertManifestTarget(mt)
   472  	dpf.st.UnlockMutableState()
   473  }
   474  
   475  func (dpf *dockerPruneFixture) withBuildCount(count int) {
   476  	store := dpf.st.LockMutableStateForTesting()
   477  	store.CompletedBuildCount = count
   478  	dpf.st.UnlockMutableState()
   479  }
   480  
   481  func (dpf *dockerPruneFixture) withCurrentlyBuilding(mn model.ManifestName) {
   482  	store := dpf.st.LockMutableStateForTesting()
   483  	store.CurrentBuildSet[mn] = true
   484  	dpf.st.UnlockMutableState()
   485  }
   486  
   487  func (dpf *dockerPruneFixture) withDockerPruneSettings(enabled bool, maxAge time.Duration, numBuilds int, interval time.Duration) {
   488  	settings := model.DockerPruneSettings{
   489  		Enabled:   enabled,
   490  		MaxAge:    maxAge,
   491  		NumBuilds: numBuilds,
   492  		Interval:  interval,
   493  	}
   494  	store := dpf.st.LockMutableStateForTesting()
   495  	store.DockerPruneSettings = settings
   496  	dpf.st.UnlockMutableState()
   497  }
   498  
   499  func (dpf *dockerPruneFixture) pruneCalled() bool {
   500  	// ContainerPrune was called -- we use this as a proxy for dp.Prune having been called.
   501  	return dpf.dCli.ContainersPruneFilters.Len() > 0
   502  }
   503  
   504  func (dpf *dockerPruneFixture) assertPrune() {
   505  	if !dpf.pruneCalled() {
   506  		dpf.t.Errorf("expected Prune() to be called, but it was not")
   507  		dpf.t.FailNow()
   508  	}
   509  	if time.Since(dpf.dp.lastPruneTime) > time.Second {
   510  		dpf.t.Errorf("Prune() was called, but dp.lastPruneTime was not updated/" +
   511  			"not updated recently")
   512  		dpf.t.FailNow()
   513  	}
   514  }
   515  
   516  func (dpf *dockerPruneFixture) assertNoPrune() {
   517  	if dpf.pruneCalled() {
   518  		dpf.t.Errorf("Prune() was called, when no calls expected")
   519  		dpf.t.FailNow()
   520  	}
   521  }