From 6ea563c6ac7599a8923a8288e43d780301360d8b Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Thu, 11 Dec 2025 13:38:52 +0100 Subject: [PATCH] Fix: Prevent coolify-helper and coolify-realtime images from being pruned MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Current version of infrastructure images (coolify-helper, coolify-realtime) are now protected from deletion during docker cleanup, regardless of which registry they're pulled from (ghcr.io, docker.io, or Docker Hub implicit). Old versions continue to be cleaned up as intended. 🤖 Generated with Claude Code Co-Authored-By: Claude --- app/Actions/Server/CleanupDocker.php | 72 ++++++++++----- .../Unit/Actions/Server/CleanupDockerTest.php | 89 +++++++++++++++++++ 2 files changed, 137 insertions(+), 24 deletions(-) diff --git a/app/Actions/Server/CleanupDocker.php b/app/Actions/Server/CleanupDocker.php index 076f7d0c5..65a41db18 100644 --- a/app/Actions/Server/CleanupDocker.php +++ b/app/Actions/Server/CleanupDocker.php @@ -37,9 +37,15 @@ public function handle(Server $server, bool $deleteUnusedVolumes = false, bool $ $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); + // Build image prune command that excludes application images and current Coolify infrastructure images + // This ensures we clean up non-Coolify images while preserving rollback images and current helper/realtime images + // Note: Only the current version is protected; old versions will be cleaned up by explicit commands below + // We pass the version strings so all registry variants are protected (ghcr.io, docker.io, no prefix) + $imagePruneCmd = $this->buildImagePruneCommand( + $applicationImageRepos, + $helperImageVersion, + $realtimeImageVersion + ); $commands = [ 'docker container prune -f --filter "label=coolify.managed=true" --filter "label!=coolify.proxy=true"', @@ -78,33 +84,51 @@ public function handle(Server $server, bool $deleteUnusedVolumes = false, bool $ * 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 - { + private function buildImagePruneCommand( + $applicationImageRepos, + string $helperImageVersion, + string $realtimeImageVersion + ): 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 extended regex (ERE) - // ERE special chars: . \ + * ? [ ^ ] $ ( ) { } | - return preg_replace('/([.\\\\+*?\[\]^$(){}|])/', '\\\\$1', $repo); - })->implode('|'); + // Build grep pattern to exclude application image repositories (matches repo:tag and repo_service:tag) + $appExcludePatterns = $applicationImageRepos->map(function ($repo) { + // Escape special characters for grep extended regex (ERE) + // ERE special chars: . \ + * ? [ ^ ] $ ( ) { } | + return preg_replace('/([.\\\\+*?\[\]^$(){}|])/', '\\\\$1', $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 - // Pattern matches both uuid:tag and uuid_servicename:tag (Docker Compose with build) - $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"; + // Build grep pattern to exclude Coolify infrastructure images (current version only) + // This pattern matches the image name regardless of registry prefix: + // - ghcr.io/coollabsio/coolify-helper:1.0.12 + // - docker.io/coollabsio/coolify-helper:1.0.12 + // - coollabsio/coolify-helper:1.0.12 + // Pattern: (^|/)coollabsio/coolify-(helper|realtime):VERSION$ + $escapedHelperVersion = preg_replace('/([.\\\\+*?\[\]^$(){}|])/', '\\\\$1', $helperImageVersion); + $escapedRealtimeVersion = preg_replace('/([.\\\\+*?\[\]^$(){}|])/', '\\\\$1', $realtimeImageVersion); + $infraExcludePattern = "(^|/)coollabsio/coolify-helper:{$escapedHelperVersion}$|(^|/)coollabsio/coolify-realtime:{$escapedRealtimeVersion}$"; + + // Delete unused images that: + // - Are not application images (don't match app repos) + // - Are not current Coolify infrastructure images (any registry) + // - Don't have coolify.managed=true label + // Images in use by containers will fail silently with docker rmi + // Pattern matches both uuid:tag and uuid_servicename:tag (Docker Compose with build) + $grepCommands = "grep -v ''"; + + // Add application repo exclusion if there are applications + if ($applicationImageRepos->isNotEmpty()) { + $grepCommands .= " | grep -v -E '^({$appExcludePatterns})[_:].+'"; } + // Add infrastructure image exclusion (matches any registry prefix) + $grepCommands .= " | grep -v -E '{$infraExcludePattern}'"; + + $commands[] = "docker images --format '{{.Repository}}:{{.Tag}}' | ". + $grepCommands.' | '. + "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); } diff --git a/tests/Unit/Actions/Server/CleanupDockerTest.php b/tests/Unit/Actions/Server/CleanupDockerTest.php index ebf73da06..630b1bf53 100644 --- a/tests/Unit/Actions/Server/CleanupDockerTest.php +++ b/tests/Unit/Actions/Server/CleanupDockerTest.php @@ -251,3 +251,92 @@ 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); +});