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