'app-uuid', 'tag' => 'abc123', 'created_at' => '2024-01-01', 'image_ref' => 'app-uuid:abc123'], ['repository' => 'app-uuid', 'tag' => 'def456', 'created_at' => '2024-01-02', 'image_ref' => 'app-uuid:def456'], ['repository' => 'app-uuid', 'tag' => 'pr-123', 'created_at' => '2024-01-03', 'image_ref' => 'app-uuid:pr-123'], ['repository' => 'app-uuid', 'tag' => 'pr-456', 'created_at' => '2024-01-04', 'image_ref' => 'app-uuid:pr-456'], ['repository' => 'app-uuid', 'tag' => 'abc123-build', 'created_at' => '2024-01-05', 'image_ref' => 'app-uuid:abc123-build'], ['repository' => 'app-uuid', 'tag' => 'def456-build', 'created_at' => '2024-01-06', 'image_ref' => 'app-uuid:def456-build'], ]); // PR images (tags starting with 'pr-') $prImages = $images->filter(fn ($image) => str_starts_with($image['tag'], 'pr-')); expect($prImages)->toHaveCount(2); expect($prImages->pluck('tag')->toArray())->toContain('pr-123', 'pr-456'); // Build images (tags ending with '-build', excluding PR builds) $buildImages = $images->filter(fn ($image) => ! str_starts_with($image['tag'], 'pr-') && str_ends_with($image['tag'], '-build')); expect($buildImages)->toHaveCount(2); expect($buildImages->pluck('tag')->toArray())->toContain('abc123-build', 'def456-build'); // Regular images (neither PR nor build) - these are subject to retention policy $regularImages = $images->filter(fn ($image) => ! str_starts_with($image['tag'], 'pr-') && ! str_ends_with($image['tag'], '-build')); expect($regularImages)->toHaveCount(2); expect($regularImages->pluck('tag')->toArray())->toContain('abc123', 'def456'); }); it('filters out currently running image from deletion candidates', function () { $images = collect([ ['repository' => 'app-uuid', 'tag' => 'abc123', 'created_at' => '2024-01-01', 'image_ref' => 'app-uuid:abc123'], ['repository' => 'app-uuid', 'tag' => 'def456', 'created_at' => '2024-01-02', 'image_ref' => 'app-uuid:def456'], ['repository' => 'app-uuid', 'tag' => 'ghi789', 'created_at' => '2024-01-03', 'image_ref' => 'app-uuid:ghi789'], ]); $currentTag = 'def456'; $deletionCandidates = $images->filter(fn ($image) => $image['tag'] !== $currentTag); expect($deletionCandidates)->toHaveCount(2); expect($deletionCandidates->pluck('tag')->toArray())->not->toContain('def456'); expect($deletionCandidates->pluck('tag')->toArray())->toContain('abc123', 'ghi789'); }); it('keeps the correct number of images based on docker_images_to_keep setting', function () { $images = collect([ ['repository' => 'app-uuid', 'tag' => 'commit1', 'created_at' => '2024-01-01 10:00:00', 'image_ref' => 'app-uuid:commit1'], ['repository' => 'app-uuid', 'tag' => 'commit2', 'created_at' => '2024-01-02 10:00:00', 'image_ref' => 'app-uuid:commit2'], ['repository' => 'app-uuid', 'tag' => 'commit3', 'created_at' => '2024-01-03 10:00:00', 'image_ref' => 'app-uuid:commit3'], ['repository' => 'app-uuid', 'tag' => 'commit4', 'created_at' => '2024-01-04 10:00:00', 'image_ref' => 'app-uuid:commit4'], ['repository' => 'app-uuid', 'tag' => 'commit5', 'created_at' => '2024-01-05 10:00:00', 'image_ref' => 'app-uuid:commit5'], ]); $currentTag = 'commit5'; $imagesToKeep = 2; // Filter out current, sort by date descending, keep N $sortedImages = $images ->filter(fn ($image) => $image['tag'] !== $currentTag) ->sortByDesc('created_at') ->values(); $imagesToDelete = $sortedImages->skip($imagesToKeep); // Should delete commit1, commit2 (oldest 2 after keeping 2 newest: commit4, commit3) expect($imagesToDelete)->toHaveCount(2); expect($imagesToDelete->pluck('tag')->toArray())->toContain('commit1', 'commit2'); }); it('deletes all images when docker_images_to_keep is 0', function () { $images = collect([ ['repository' => 'app-uuid', 'tag' => 'commit1', 'created_at' => '2024-01-01 10:00:00', 'image_ref' => 'app-uuid:commit1'], ['repository' => 'app-uuid', 'tag' => 'commit2', 'created_at' => '2024-01-02 10:00:00', 'image_ref' => 'app-uuid:commit2'], ['repository' => 'app-uuid', 'tag' => 'commit3', 'created_at' => '2024-01-03 10:00:00', 'image_ref' => 'app-uuid:commit3'], ]); $currentTag = 'commit3'; $imagesToKeep = 0; $sortedImages = $images ->filter(fn ($image) => $image['tag'] !== $currentTag) ->sortByDesc('created_at') ->values(); $imagesToDelete = $sortedImages->skip($imagesToKeep); // Should delete all images except the currently running one expect($imagesToDelete)->toHaveCount(2); expect($imagesToDelete->pluck('tag')->toArray())->toContain('commit1', 'commit2'); }); it('does not delete any images when there are fewer than images_to_keep', function () { $images = collect([ ['repository' => 'app-uuid', 'tag' => 'commit1', 'created_at' => '2024-01-01 10:00:00', 'image_ref' => 'app-uuid:commit1'], ['repository' => 'app-uuid', 'tag' => 'commit2', 'created_at' => '2024-01-02 10:00:00', 'image_ref' => 'app-uuid:commit2'], ]); $currentTag = 'commit2'; $imagesToKeep = 5; $sortedImages = $images ->filter(fn ($image) => $image['tag'] !== $currentTag) ->sortByDesc('created_at') ->values(); $imagesToDelete = $sortedImages->skip($imagesToKeep); // Should not delete anything - we have fewer images than the keep limit expect($imagesToDelete)->toHaveCount(0); }); it('handles images with custom registry names', function () { // Test that the logic works regardless of repository name format $images = collect([ ['repository' => 'registry.example.com/my-app', 'tag' => 'v1.0.0', 'created_at' => '2024-01-01', 'image_ref' => 'registry.example.com/my-app:v1.0.0'], ['repository' => 'registry.example.com/my-app', 'tag' => 'v1.1.0', 'created_at' => '2024-01-02', 'image_ref' => 'registry.example.com/my-app:v1.1.0'], ['repository' => 'registry.example.com/my-app', 'tag' => 'pr-99', 'created_at' => '2024-01-03', 'image_ref' => 'registry.example.com/my-app:pr-99'], ]); $prImages = $images->filter(fn ($image) => str_starts_with($image['tag'], 'pr-')); $regularImages = $images->filter(fn ($image) => ! str_starts_with($image['tag'], 'pr-') && ! str_ends_with($image['tag'], '-build')); expect($prImages)->toHaveCount(1); expect($regularImages)->toHaveCount(2); }); it('correctly identifies PR build images as PR images', function () { // PR build images have tags like 'pr-123-build' // They are identified as PR images (start with 'pr-') and will be deleted $images = collect([ ['repository' => 'app-uuid', 'tag' => 'pr-123', 'created_at' => '2024-01-01', 'image_ref' => 'app-uuid:pr-123'], ['repository' => 'app-uuid', 'tag' => 'pr-123-build', 'created_at' => '2024-01-02', 'image_ref' => 'app-uuid:pr-123-build'], ]); // PR images include both pr-123 and pr-123-build (both start with 'pr-') $prImages = $images->filter(fn ($image) => str_starts_with($image['tag'], 'pr-')); expect($prImages)->toHaveCount(2); }); it('defaults to keeping 2 images when setting is null', function () { $defaultValue = 2; // Simulate the null coalescing behavior $dockerImagesToKeep = null ?? $defaultValue; expect($dockerImagesToKeep)->toBe(2); }); it('does not delete images when count equals images_to_keep', function () { // Scenario: User has 3 images, 1 is running, 2 remain, keep limit is 2 // Expected: No images should be deleted $images = collect([ ['repository' => 'app-uuid', 'tag' => 'commit1', 'created_at' => '2024-01-01 10:00:00', 'image_ref' => 'app-uuid:commit1'], ['repository' => 'app-uuid', 'tag' => 'commit2', 'created_at' => '2024-01-02 10:00:00', 'image_ref' => 'app-uuid:commit2'], ['repository' => 'app-uuid', 'tag' => 'commit3', 'created_at' => '2024-01-03 10:00:00', 'image_ref' => 'app-uuid:commit3'], ]); $currentTag = 'commit3'; // This is running $imagesToKeep = 2; $sortedImages = $images ->filter(fn ($image) => $image['tag'] !== $currentTag) ->sortByDesc('created_at') ->values(); // After filtering out running image, we have 2 images expect($sortedImages)->toHaveCount(2); $imagesToDelete = $sortedImages->skip($imagesToKeep); // Skip 2, leaving 0 to delete expect($imagesToDelete)->toHaveCount(0); }); it('handles scenario where no container is running', function () { // Scenario: 2 images exist, none running, keep limit is 2 // Expected: No images should be deleted (keep all 2) $images = collect([ ['repository' => 'app-uuid', 'tag' => 'commit1', 'created_at' => '2024-01-01 10:00:00', 'image_ref' => 'app-uuid:commit1'], ['repository' => 'app-uuid', 'tag' => 'commit2', 'created_at' => '2024-01-02 10:00:00', 'image_ref' => 'app-uuid:commit2'], ]); $currentTag = ''; // No container running, empty tag $imagesToKeep = 2; $sortedImages = $images ->filter(fn ($image) => $image['tag'] !== $currentTag) ->sortByDesc('created_at') ->values(); // All images remain since none match the empty current tag expect($sortedImages)->toHaveCount(2); $imagesToDelete = $sortedImages->skip($imagesToKeep); // Skip 2, leaving 0 to delete expect($imagesToDelete)->toHaveCount(0); }); it('handles Docker Compose service images with uuid_servicename pattern', function () { // Docker Compose with build: directive creates images like uuid_servicename:tag $images = collect([ ['repository' => 'app-uuid_web', 'tag' => 'commit1', 'created_at' => '2024-01-01 10:00:00', 'image_ref' => 'app-uuid_web:commit1'], ['repository' => 'app-uuid_web', 'tag' => 'commit2', 'created_at' => '2024-01-02 10:00:00', 'image_ref' => 'app-uuid_web:commit2'], ['repository' => 'app-uuid_worker', 'tag' => 'commit1', 'created_at' => '2024-01-01 10:00:00', 'image_ref' => 'app-uuid_worker:commit1'], ['repository' => 'app-uuid_worker', 'tag' => 'commit2', 'created_at' => '2024-01-02 10:00:00', 'image_ref' => 'app-uuid_worker:commit2'], ]); // All images should be categorized as regular images (not PR, not build) $prImages = $images->filter(fn ($image) => str_starts_with($image['tag'], 'pr-')); $regularImages = $images->filter(fn ($image) => ! str_starts_with($image['tag'], 'pr-') && ! str_ends_with($image['tag'], '-build')); expect($prImages)->toHaveCount(0); expect($regularImages)->toHaveCount(4); }); it('correctly excludes Docker Compose images from general prune', function () { // Test the grep pattern that excludes application images // Pattern should match both uuid:tag and uuid_servicename:tag $appUuid = 'abc123def456'; $excludePattern = preg_quote($appUuid, '/'); // Images that should be EXCLUDED (protected) $protectedImages = [ "{$appUuid}:commit1", // Standard app image "{$appUuid}_web:commit1", // Docker Compose service "{$appUuid}_worker:commit2", // Docker Compose service ]; // Images that should be INCLUDED (deleted) $deletableImages = [ 'other-app:latest', 'nginx:alpine', 'postgres:15', ]; // Test the regex pattern used in buildImagePruneCommand $pattern = "/^({$excludePattern})[_:].+/"; foreach ($protectedImages as $image) { expect(preg_match($pattern, $image))->toBe(1, "Image {$image} should be protected"); } foreach ($deletableImages as $image) { expect(preg_match($pattern, $image))->toBe(0, "Image {$image} should be deletable"); } }); it('excludes current version of Coolify infrastructure images from any registry', function () { // Test the regex pattern used to protect the current version of infrastructure images // regardless of which registry they come from (ghcr.io, docker.io, or no prefix) $helperVersion = '1.0.12'; $realtimeVersion = '1.0.10'; // Build the exclusion pattern the same way CleanupDocker does // Pattern: (^|/)coollabsio/coolify-helper:VERSION$|(^|/)coollabsio/coolify-realtime:VERSION$ $escapedHelperVersion = preg_replace('/([.\\\\+*?\[\]^$(){}|])/', '\\\\$1', $helperVersion); $escapedRealtimeVersion = preg_replace('/([.\\\\+*?\[\]^$(){}|])/', '\\\\$1', $realtimeVersion); // For PHP preg_match, escape forward slashes $infraPattern = "(^|\\/)coollabsio\\/coolify-helper:{$escapedHelperVersion}$|(^|\\/)coollabsio\\/coolify-realtime:{$escapedRealtimeVersion}$"; $pattern = "/{$infraPattern}/"; // Current versioned infrastructure images from ANY registry should be PROTECTED $protectedImages = [ // ghcr.io registry "ghcr.io/coollabsio/coolify-helper:{$helperVersion}", "ghcr.io/coollabsio/coolify-realtime:{$realtimeVersion}", // docker.io registry (explicit) "docker.io/coollabsio/coolify-helper:{$helperVersion}", "docker.io/coollabsio/coolify-realtime:{$realtimeVersion}", // No registry prefix (Docker Hub implicit) "coollabsio/coolify-helper:{$helperVersion}", "coollabsio/coolify-realtime:{$realtimeVersion}", ]; // Verify current infrastructure images ARE protected from any registry foreach ($protectedImages as $image) { expect(preg_match($pattern, $image))->toBe(1, "Current infrastructure image {$image} should be protected"); } // Verify OLD versions of infrastructure images are NOT protected (can be deleted) $oldVersionImages = [ 'ghcr.io/coollabsio/coolify-helper:1.0.11', 'docker.io/coollabsio/coolify-helper:1.0.10', 'coollabsio/coolify-helper:1.0.9', 'ghcr.io/coollabsio/coolify-realtime:1.0.9', 'ghcr.io/coollabsio/coolify-helper:latest', 'coollabsio/coolify-realtime:latest', ]; foreach ($oldVersionImages as $image) { expect(preg_match($pattern, $image))->toBe(0, "Old infrastructure image {$image} should NOT be protected"); } // Verify other images are NOT protected (can be deleted) $deletableImages = [ 'nginx:alpine', 'postgres:15', 'redis:7', 'mysql:8.0', 'node:20', ]; foreach ($deletableImages as $image) { expect(preg_match($pattern, $image))->toBe(0, "Image {$image} should NOT be protected"); } }); it('protects current infrastructure images from any registry even when no applications exist', function () { // When there are no applications, current versioned infrastructure images should still be protected // regardless of which registry they come from $helperVersion = '1.0.12'; $realtimeVersion = '1.0.10'; // Build the pattern the same way CleanupDocker does $escapedHelperVersion = preg_replace('/([.\\\\+*?\[\]^$(){}|])/', '\\\\$1', $helperVersion); $escapedRealtimeVersion = preg_replace('/([.\\\\+*?\[\]^$(){}|])/', '\\\\$1', $realtimeVersion); // For PHP preg_match, escape forward slashes $infraPattern = "(^|\\/)coollabsio\\/coolify-helper:{$escapedHelperVersion}$|(^|\\/)coollabsio\\/coolify-realtime:{$escapedRealtimeVersion}$"; $pattern = "/{$infraPattern}/"; // Verify current infrastructure images from any registry are protected expect(preg_match($pattern, "ghcr.io/coollabsio/coolify-helper:{$helperVersion}"))->toBe(1); expect(preg_match($pattern, "docker.io/coollabsio/coolify-helper:{$helperVersion}"))->toBe(1); expect(preg_match($pattern, "coollabsio/coolify-helper:{$helperVersion}"))->toBe(1); expect(preg_match($pattern, "ghcr.io/coollabsio/coolify-realtime:{$realtimeVersion}"))->toBe(1); // Old versions should NOT be protected expect(preg_match($pattern, 'ghcr.io/coollabsio/coolify-helper:1.0.11'))->toBe(0); expect(preg_match($pattern, 'docker.io/coollabsio/coolify-helper:1.0.11'))->toBe(0); // Other images should not be protected expect(preg_match($pattern, 'nginx:alpine'))->toBe(0); }); it('deletes build images not matching retained regular images', function () { // Simulates the Nixpacks scenario from issue #8765: // Many -build images accumulate because they were excluded from both cleanup paths $images = collect([ ['repository' => 'app-uuid', 'tag' => 'commit1', 'created_at' => '2024-01-01 10:00:00', 'image_ref' => 'app-uuid:commit1'], ['repository' => 'app-uuid', 'tag' => 'commit2', 'created_at' => '2024-01-02 10:00:00', 'image_ref' => 'app-uuid:commit2'], ['repository' => 'app-uuid', 'tag' => 'commit3', 'created_at' => '2024-01-03 10:00:00', 'image_ref' => 'app-uuid:commit3'], ['repository' => 'app-uuid', 'tag' => 'commit4', 'created_at' => '2024-01-04 10:00:00', 'image_ref' => 'app-uuid:commit4'], ['repository' => 'app-uuid', 'tag' => 'commit5', 'created_at' => '2024-01-05 10:00:00', 'image_ref' => 'app-uuid:commit5'], ['repository' => 'app-uuid', 'tag' => 'commit1-build', 'created_at' => '2024-01-01 09:00:00', 'image_ref' => 'app-uuid:commit1-build'], ['repository' => 'app-uuid', 'tag' => 'commit2-build', 'created_at' => '2024-01-02 09:00:00', 'image_ref' => 'app-uuid:commit2-build'], ['repository' => 'app-uuid', 'tag' => 'commit3-build', 'created_at' => '2024-01-03 09:00:00', 'image_ref' => 'app-uuid:commit3-build'], ['repository' => 'app-uuid', 'tag' => 'commit4-build', 'created_at' => '2024-01-04 09:00:00', 'image_ref' => 'app-uuid:commit4-build'], ['repository' => 'app-uuid', 'tag' => 'commit5-build', 'created_at' => '2024-01-05 09:00:00', 'image_ref' => 'app-uuid:commit5-build'], ]); $currentTag = 'commit5'; $imagesToKeep = 2; $buildImages = $images->filter(fn ($image) => ! str_starts_with($image['tag'], 'pr-') && str_ends_with($image['tag'], '-build')); $regularImages = $images->filter(fn ($image) => ! str_starts_with($image['tag'], 'pr-') && ! str_ends_with($image['tag'], '-build')); $sortedRegularImages = $regularImages ->filter(fn ($image) => $image['tag'] !== $currentTag) ->sortByDesc('created_at') ->values(); // Determine kept tags: current + N newest rollback $keptTags = $sortedRegularImages->take($imagesToKeep)->pluck('tag'); if (! empty($currentTag)) { $keptTags = $keptTags->push($currentTag); } // Kept tags should be: commit5 (running), commit4, commit3 (2 newest rollback) expect($keptTags->toArray())->toContain('commit5', 'commit4', 'commit3'); // Build images to delete: those whose base tag is NOT in keptTags $buildImagesToDelete = $buildImages->filter(function ($image) use ($keptTags) { $baseTag = preg_replace('/-build$/', '', $image['tag']); return ! $keptTags->contains($baseTag); }); // Should delete commit1-build and commit2-build (their base tags are not kept) expect($buildImagesToDelete)->toHaveCount(2); expect($buildImagesToDelete->pluck('tag')->toArray())->toContain('commit1-build', 'commit2-build'); // Should keep commit3-build, commit4-build, commit5-build (matching retained images) $buildImagesToKeep = $buildImages->filter(function ($image) use ($keptTags) { $baseTag = preg_replace('/-build$/', '', $image['tag']); return $keptTags->contains($baseTag); }); expect($buildImagesToKeep)->toHaveCount(3); expect($buildImagesToKeep->pluck('tag')->toArray())->toContain('commit5-build', 'commit4-build', 'commit3-build'); }); it('deletes all build images when retention is disabled', function () { $images = collect([ ['repository' => 'app-uuid', 'tag' => 'commit1', 'created_at' => '2024-01-01 10:00:00', 'image_ref' => 'app-uuid:commit1'], ['repository' => 'app-uuid', 'tag' => 'commit2', 'created_at' => '2024-01-02 10:00:00', 'image_ref' => 'app-uuid:commit2'], ['repository' => 'app-uuid', 'tag' => 'commit1-build', 'created_at' => '2024-01-01 09:00:00', 'image_ref' => 'app-uuid:commit1-build'], ['repository' => 'app-uuid', 'tag' => 'commit2-build', 'created_at' => '2024-01-02 09:00:00', 'image_ref' => 'app-uuid:commit2-build'], ]); $currentTag = 'commit2'; $imagesToKeep = 0; // Retention disabled $buildImages = $images->filter(fn ($image) => ! str_starts_with($image['tag'], 'pr-') && str_ends_with($image['tag'], '-build')); $regularImages = $images->filter(fn ($image) => ! str_starts_with($image['tag'], 'pr-') && ! str_ends_with($image['tag'], '-build')); $sortedRegularImages = $regularImages ->filter(fn ($image) => $image['tag'] !== $currentTag) ->sortByDesc('created_at') ->values(); // With imagesToKeep=0, only current tag is kept $keptTags = $sortedRegularImages->take($imagesToKeep)->pluck('tag'); if (! empty($currentTag)) { $keptTags = $keptTags->push($currentTag); } $buildImagesToDelete = $buildImages->filter(function ($image) use ($keptTags) { $baseTag = preg_replace('/-build$/', '', $image['tag']); return ! $keptTags->contains($baseTag); }); // commit1-build should be deleted (not retained), commit2-build kept (matches running) expect($buildImagesToDelete)->toHaveCount(1); expect($buildImagesToDelete->pluck('tag')->toArray())->toContain('commit1-build'); }); it('preserves build image for currently running tag', function () { $images = collect([ ['repository' => 'app-uuid', 'tag' => 'commit1', 'created_at' => '2024-01-01 10:00:00', 'image_ref' => 'app-uuid:commit1'], ['repository' => 'app-uuid', 'tag' => 'commit1-build', 'created_at' => '2024-01-01 09:00:00', 'image_ref' => 'app-uuid:commit1-build'], ]); $currentTag = 'commit1'; $imagesToKeep = 2; $buildImages = $images->filter(fn ($image) => ! str_starts_with($image['tag'], 'pr-') && str_ends_with($image['tag'], '-build')); $regularImages = $images->filter(fn ($image) => ! str_starts_with($image['tag'], 'pr-') && ! str_ends_with($image['tag'], '-build')); $sortedRegularImages = $regularImages ->filter(fn ($image) => $image['tag'] !== $currentTag) ->sortByDesc('created_at') ->values(); $keptTags = $sortedRegularImages->take($imagesToKeep)->pluck('tag'); if (! empty($currentTag)) { $keptTags = $keptTags->push($currentTag); } $buildImagesToDelete = $buildImages->filter(function ($image) use ($keptTags) { $baseTag = preg_replace('/-build$/', '', $image['tag']); return ! $keptTags->contains($baseTag); }); // Build image for running tag should NOT be deleted expect($buildImagesToDelete)->toHaveCount(0); });