From 4ed7a4238a500427ac53684331ffc752e94a2805 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Fri, 5 Dec 2025 11:02:07 +0100 Subject: [PATCH] Add per-application Docker image retention for rollback capability MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement a per-application setting (`docker_images_to_keep`) in `application_settings` table to control how many Docker images are preserved during cleanup. The cleanup process now: - Respects per-application retention settings (default: 2 images) - Preserves the N most recent images per application for easy rollback - Always deletes PR images and keeps the currently running image - Dynamically excludes application images from general Docker image prune - Cleans up non-Coolify unused images to prevent disk bloat Fixes issues where cleanup would delete all images needed for rollback. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- app/Actions/Server/CleanupDocker.php | 133 +++++++++++- app/Livewire/Project/Application/Rollback.php | 24 ++- app/Models/ApplicationSetting.php | 1 + ...images_to_keep_to_application_settings.php | 22 ++ .../project/application/rollback.blade.php | 17 +- .../Unit/Actions/Server/CleanupDockerTest.php | 204 ++++++++++++++++++ 6 files changed, 390 insertions(+), 11 deletions(-) create mode 100644 database/migrations/2025_12_05_000000_add_docker_images_to_keep_to_application_settings.php create mode 100644 tests/Unit/Actions/Server/CleanupDockerTest.php diff --git a/app/Actions/Server/CleanupDocker.php b/app/Actions/Server/CleanupDocker.php index 6bf094c32..df8ac7193 100644 --- a/app/Actions/Server/CleanupDocker.php +++ b/app/Actions/Server/CleanupDocker.php @@ -13,7 +13,6 @@ class CleanupDocker public function handle(Server $server, bool $deleteUnusedVolumes = false, bool $deleteUnusedNetworks = false) { - $settings = instanceSettings(); $realtimeImage = config('constants.coolify.realtime_image'); $realtimeImageVersion = config('constants.coolify.realtime_version'); $realtimeImageWithVersion = "$realtimeImage:$realtimeImageVersion"; @@ -26,9 +25,25 @@ public function handle(Server $server, bool $deleteUnusedVolumes = false, bool $ $helperImageWithoutPrefix = 'coollabsio/coolify-helper'; $helperImageWithoutPrefixVersion = "coollabsio/coolify-helper:$helperImageVersion"; + $cleanupLog = []; + + // Get all application image repositories to exclude from prune + $applications = $server->applications(); + $applicationImageRepos = collect($applications)->map(function ($app) { + return $app->docker_registry_image_name ?? $app->uuid; + })->unique()->values(); + + // Clean up old application images while preserving N most recent for rollback + $applicationCleanupLog = $this->cleanupApplicationImages($server, $applications); + $cleanupLog = array_merge($cleanupLog, $applicationCleanupLog); + + // Build image prune command that excludes application images + // This ensures we clean up non-Coolify images while preserving rollback images + $imagePruneCmd = $this->buildImagePruneCommand($applicationImageRepos); + $commands = [ 'docker container prune -f --filter "label=coolify.managed=true" --filter "label!=coolify.proxy=true"', - 'docker image prune -af --filter "label!=coolify.managed=true"', + $imagePruneCmd, 'docker builder prune -af', "docker images --filter before=$helperImageWithVersion --filter reference=$helperImage | grep $helperImage | awk '{print $3}' | xargs -r docker rmi -f", "docker images --filter before=$realtimeImageWithVersion --filter reference=$realtimeImage | grep $realtimeImage | awk '{print $3}' | xargs -r docker rmi -f", @@ -44,7 +59,6 @@ public function handle(Server $server, bool $deleteUnusedVolumes = false, bool $ $commands[] = 'docker network prune -f'; } - $cleanupLog = []; foreach ($commands as $command) { $commandOutput = instant_remote_process([$command], $server, false); if ($commandOutput !== null) { @@ -57,4 +71,117 @@ public function handle(Server $server, bool $deleteUnusedVolumes = false, bool $ return $cleanupLog; } + + /** + * Build a docker image prune command that excludes application image repositories. + * + * Since docker image prune doesn't support excluding by repository name directly, + * we use a shell script approach to delete unused images while preserving application images. + */ + private function buildImagePruneCommand($applicationImageRepos): string + { + // Step 1: Always prune dangling images (untagged) + $commands = ['docker image prune -f']; + + if ($applicationImageRepos->isEmpty()) { + // No applications, add original prune command for all unused images + $commands[] = 'docker image prune -af --filter "label!=coolify.managed=true"'; + } else { + // Build grep pattern to exclude application image repositories + $excludePatterns = $applicationImageRepos->map(function ($repo) { + // Escape special characters for grep basic regex + return preg_quote($repo, '/'); + })->implode('|'); + + // Delete unused images that: + // - Are not application images (don't match app repos) + // - Don't have coolify.managed=true label + // Images in use by containers will fail silently with docker rmi + $commands[] = "docker images --format '{{.Repository}}:{{.Tag}}' | ". + "grep -v -E '^({$excludePatterns}):' | ". + "grep -v '' | ". + "xargs -r -I {} sh -c 'docker inspect --format \"{{{{index .Config.Labels \\\"coolify.managed\\\"}}}}\" \"{}\" 2>/dev/null | grep -q true || docker rmi \"{}\" 2>/dev/null' || true"; + } + + return implode(' && ', $commands); + } + + private function cleanupApplicationImages(Server $server, $applications = null): array + { + $cleanupLog = []; + + if ($applications === null) { + $applications = $server->applications(); + } + + foreach ($applications as $application) { + $imagesToKeep = $application->settings->docker_images_to_keep ?? 2; + $imageRepository = $application->docker_registry_image_name ?? $application->uuid; + + // Get the currently running image tag + $currentTagCommand = "docker inspect --format='{{.Config.Image}}' {$application->uuid} 2>/dev/null | grep -oP '(?<=:)[^:]+$' || true"; + $currentTag = instant_remote_process([$currentTagCommand], $server, false); + $currentTag = trim($currentTag ?? ''); + + // List all images for this application with their creation timestamps + $listCommand = "docker images --format '{{.Repository}}:{{.Tag}}#{{.CreatedAt}}' --filter reference='{$imageRepository}' 2>/dev/null || true"; + $output = instant_remote_process([$listCommand], $server, false); + + if (empty($output)) { + continue; + } + + $images = collect(explode("\n", trim($output))) + ->filter() + ->map(function ($line) { + $parts = explode('#', $line); + $imageRef = $parts[0] ?? ''; + $tagParts = explode(':', $imageRef); + + return [ + 'repository' => $tagParts[0] ?? '', + 'tag' => $tagParts[1] ?? '', + 'created_at' => $parts[1] ?? '', + 'image_ref' => $imageRef, + ]; + }) + ->filter(fn ($image) => ! empty($image['tag'])); + + // Separate images into categories + // PR images (pr-*) and build images (*-build) are excluded from retention + // Build images will be cleaned up by docker image prune -af + $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')); + + // Always delete all PR images + foreach ($prImages as $image) { + $deleteCommand = "docker rmi {$image['image_ref']} 2>/dev/null || true"; + $deleteOutput = instant_remote_process([$deleteCommand], $server, false); + $cleanupLog[] = [ + 'command' => $deleteCommand, + 'output' => $deleteOutput ?? 'PR image removed or was in use', + ]; + } + + // Filter out current running image from regular images and sort by creation date + $sortedRegularImages = $regularImages + ->filter(fn ($image) => $image['tag'] !== $currentTag) + ->sortByDesc('created_at') + ->values(); + + // Keep only N images (imagesToKeep), delete the rest + $imagesToDelete = $sortedRegularImages->skip($imagesToKeep); + + foreach ($imagesToDelete as $image) { + $deleteCommand = "docker rmi {$image['image_ref']} 2>/dev/null || true"; + $deleteOutput = instant_remote_process([$deleteCommand], $server, false); + $cleanupLog[] = [ + 'command' => $deleteCommand, + 'output' => $deleteOutput ?? 'Image removed or was in use', + ]; + } + } + + return $cleanupLog; + } } diff --git a/app/Livewire/Project/Application/Rollback.php b/app/Livewire/Project/Application/Rollback.php index da67a5707..e3223a499 100644 --- a/app/Livewire/Project/Application/Rollback.php +++ b/app/Livewire/Project/Application/Rollback.php @@ -4,6 +4,7 @@ use App\Models\Application; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; +use Livewire\Attributes\Validate; use Livewire\Component; use Visus\Cuid2\Cuid2; @@ -19,9 +20,26 @@ class Rollback extends Component public array $parameters; + #[Validate(['integer', 'min:0', 'max:100'])] + public int $dockerImagesToKeep = 2; + public function mount() { $this->parameters = get_route_parameters(); + $this->dockerImagesToKeep = $this->application->settings->docker_images_to_keep ?? 2; + } + + public function saveSettings() + { + try { + $this->authorize('update', $this->application); + $this->validate(); + $this->application->settings->docker_images_to_keep = $this->dockerImagesToKeep; + $this->application->settings->save(); + $this->dispatch('success', 'Settings saved.'); + } catch (\Throwable $e) { + return handleError($e, $this); + } } public function rollbackImage($commit) @@ -66,14 +84,12 @@ public function loadImages($showToast = false) return str($item)->contains($image); })->map(function ($item) { $item = str($item)->explode('#'); - if ($item[1] === $this->current) { - // $is_current = true; - } + $is_current = $item[1] === $this->current; return [ 'tag' => $item[1], 'created_at' => $item[2], - 'is_current' => $is_current ?? null, + 'is_current' => $is_current, ]; })->toArray(); } diff --git a/app/Models/ApplicationSetting.php b/app/Models/ApplicationSetting.php index de545e9bb..f40977b3e 100644 --- a/app/Models/ApplicationSetting.php +++ b/app/Models/ApplicationSetting.php @@ -25,6 +25,7 @@ class ApplicationSetting extends Model 'is_git_submodules_enabled' => 'boolean', 'is_git_lfs_enabled' => 'boolean', 'is_git_shallow_clone_enabled' => 'boolean', + 'docker_images_to_keep' => 'integer', ]; protected $guarded = []; diff --git a/database/migrations/2025_12_05_000000_add_docker_images_to_keep_to_application_settings.php b/database/migrations/2025_12_05_000000_add_docker_images_to_keep_to_application_settings.php new file mode 100644 index 000000000..97547ac45 --- /dev/null +++ b/database/migrations/2025_12_05_000000_add_docker_images_to_keep_to_application_settings.php @@ -0,0 +1,22 @@ +integer('docker_images_to_keep')->default(2); + }); + } + + public function down(): void + { + Schema::table('application_settings', function (Blueprint $table) { + $table->dropColumn('docker_images_to_keep'); + }); + } +}; diff --git a/resources/views/livewire/project/application/rollback.blade.php b/resources/views/livewire/project/application/rollback.blade.php index e0b1465dc..02f7d14b2 100644 --- a/resources/views/livewire/project/application/rollback.blade.php +++ b/resources/views/livewire/project/application/rollback.blade.php @@ -5,13 +5,22 @@ Reload Available Images @endcan -
You can easily rollback to a previously built (local) images - quickly.
+
You can easily rollback to a previously built (local) images quickly.
+ +
+
+ + Save + +
@forelse ($images as $image)
-
+
@if (data_get($image, 'is_current')) @@ -49,4 +58,4 @@
Loading available docker images...
-
+
\ No newline at end of file diff --git a/tests/Unit/Actions/Server/CleanupDockerTest.php b/tests/Unit/Actions/Server/CleanupDockerTest.php new file mode 100644 index 000000000..2b28e7749 --- /dev/null +++ b/tests/Unit/Actions/Server/CleanupDockerTest.php @@ -0,0 +1,204 @@ + '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'); + + // 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); +});