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 }