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 1/6] 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); +}); From 710dc3ca4b432b70ac150eaf2e1eae313d1edfb6 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Fri, 5 Dec 2025 11:17:23 +0100 Subject: [PATCH 2/6] Add Docker Compose support for image retention during cleanup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Support for Docker Compose applications with build: directives that create images with uuid_servicename naming pattern (e.g., app-uuid_web:commit). 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- app/Actions/Server/CleanupDocker.php | 6 ++- .../Unit/Actions/Server/CleanupDockerTest.php | 49 +++++++++++++++++++ 2 files changed, 53 insertions(+), 2 deletions(-) diff --git a/app/Actions/Server/CleanupDocker.php b/app/Actions/Server/CleanupDocker.php index df8ac7193..cdbe10427 100644 --- a/app/Actions/Server/CleanupDocker.php +++ b/app/Actions/Server/CleanupDocker.php @@ -97,8 +97,9 @@ private function buildImagePruneCommand($applicationImageRepos): string // - 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 -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"; } @@ -124,7 +125,8 @@ private function cleanupApplicationImages(Server $server, $applications = null): $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"; + // Use wildcard to match both uuid:tag and uuid_servicename:tag (Docker Compose with build) + $listCommand = "docker images --format '{{.Repository}}:{{.Tag}}#{{.CreatedAt}}' --filter reference='{$imageRepository}*' 2>/dev/null || true"; $output = instant_remote_process([$listCommand], $server, false); if (empty($output)) { diff --git a/tests/Unit/Actions/Server/CleanupDockerTest.php b/tests/Unit/Actions/Server/CleanupDockerTest.php index 2b28e7749..ebf73da06 100644 --- a/tests/Unit/Actions/Server/CleanupDockerTest.php +++ b/tests/Unit/Actions/Server/CleanupDockerTest.php @@ -202,3 +202,52 @@ // 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"); + } +}); From 439afca6429add9ff7716cfc36a02ee81c5a1882 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Fri, 5 Dec 2025 11:41:47 +0100 Subject: [PATCH 3/6] Inject commit-based image tags for Docker Compose build services MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit For Docker Compose applications with build directives, inject commit-based image tags (uuid_servicename:commit) to enable rollback functionality. Previously these services always used 'latest' tags, making rollback impossible. - Only injects tags for services with build: but no explicit image: - Uses pr-{id} tags for pull request deployments - Respects user-defined image: fields (preserves user intent) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- app/Jobs/ApplicationDeploymentJob.php | 2 +- app/Models/Application.php | 4 +- bootstrap/helpers/parsers.php | 16 +- .../Parsers/ApplicationParserImageTagTest.php | 171 ++++++++++++++++++ 4 files changed, 189 insertions(+), 4 deletions(-) create mode 100644 tests/Unit/Parsers/ApplicationParserImageTagTest.php diff --git a/app/Jobs/ApplicationDeploymentJob.php b/app/Jobs/ApplicationDeploymentJob.php index 74c26db77..6b13d2cb7 100644 --- a/app/Jobs/ApplicationDeploymentJob.php +++ b/app/Jobs/ApplicationDeploymentJob.php @@ -620,7 +620,7 @@ private function deploy_docker_compose_buildpack() $this->application_deployment_queue->addLogEntry('Build secrets are configured. Ensure your docker-compose file includes build.secrets configuration for services that need them.'); } } else { - $composeFile = $this->application->parse(pull_request_id: $this->pull_request_id, preview_id: data_get($this->preview, 'id')); + $composeFile = $this->application->parse(pull_request_id: $this->pull_request_id, preview_id: data_get($this->preview, 'id'), commit: $this->commit); // Always add .env file to services $services = collect(data_get($composeFile, 'services', [])); $services = $services->map(function ($service, $name) { diff --git a/app/Models/Application.php b/app/Models/Application.php index 7bddce32b..118245546 100644 --- a/app/Models/Application.php +++ b/app/Models/Application.php @@ -1500,10 +1500,10 @@ public function oldRawParser() instant_remote_process($commands, $this->destination->server, false); } - public function parse(int $pull_request_id = 0, ?int $preview_id = null) + public function parse(int $pull_request_id = 0, ?int $preview_id = null, ?string $commit = null) { if ((int) $this->compose_parsing_version >= 3) { - return applicationParser($this, $pull_request_id, $preview_id); + return applicationParser($this, $pull_request_id, $preview_id, $commit); } elseif ($this->docker_compose_raw) { return parseDockerComposeFile(resource: $this, isNew: false, pull_request_id: $pull_request_id, preview_id: $preview_id); } else { diff --git a/bootstrap/helpers/parsers.php b/bootstrap/helpers/parsers.php index e7d875777..d58a4b4fe 100644 --- a/bootstrap/helpers/parsers.php +++ b/bootstrap/helpers/parsers.php @@ -358,7 +358,7 @@ function parseDockerVolumeString(string $volumeString): array ]; } -function applicationParser(Application $resource, int $pull_request_id = 0, ?int $preview_id = null): Collection +function applicationParser(Application $resource, int $pull_request_id = 0, ?int $preview_id = null, ?string $commit = null): Collection { $uuid = data_get($resource, 'uuid'); $compose = data_get($resource, 'docker_compose_raw'); @@ -1324,6 +1324,20 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int ->values(); $payload['env_file'] = $envFiles; + + // Inject commit-based image tag for services with build directive (for rollback support) + // Only inject if service has build but no explicit image defined + $hasBuild = data_get($service, 'build') !== null; + $hasImage = data_get($service, 'image') !== null; + if ($hasBuild && ! $hasImage && $commit) { + $imageTag = str($commit)->substr(0, 128)->value(); + if ($isPullRequest) { + $imageTag = "pr-{$pullRequestId}"; + } + $imageRepo = "{$uuid}_{$serviceName}"; + $payload['image'] = "{$imageRepo}:{$imageTag}"; + } + if ($isPullRequest) { $serviceName = addPreviewDeploymentSuffix($serviceName, $pullRequestId); } diff --git a/tests/Unit/Parsers/ApplicationParserImageTagTest.php b/tests/Unit/Parsers/ApplicationParserImageTagTest.php new file mode 100644 index 000000000..6593fa5e7 --- /dev/null +++ b/tests/Unit/Parsers/ApplicationParserImageTagTest.php @@ -0,0 +1,171 @@ + './app', + ]; + + $hasBuild = data_get($service, 'build') !== null; + $hasImage = data_get($service, 'image') !== null; + $commit = 'abc123def456'; + $uuid = 'app-uuid'; + $serviceName = 'web'; + + expect($hasBuild)->toBeTrue(); + expect($hasImage)->toBeFalse(); + + // Simulate the image injection logic + if ($hasBuild && ! $hasImage && $commit) { + $imageTag = str($commit)->substr(0, 128)->value(); + $imageRepo = "{$uuid}_{$serviceName}"; + $service['image'] = "{$imageRepo}:{$imageTag}"; + } + + expect($service['image'])->toBe('app-uuid_web:abc123def456'); +}); + +it('does not inject image tag when service has explicit image directive', function () { + // User has specified their own image - we respect it + $service = [ + 'build' => './app', + 'image' => 'myregistry/myapp:latest', + ]; + + $hasBuild = data_get($service, 'build') !== null; + $hasImage = data_get($service, 'image') !== null; + $commit = 'abc123def456'; + + expect($hasBuild)->toBeTrue(); + expect($hasImage)->toBeTrue(); + + // The condition should NOT trigger + $shouldInject = $hasBuild && ! $hasImage && $commit; + expect($shouldInject)->toBeFalse(); + + // Image should remain unchanged + expect($service['image'])->toBe('myregistry/myapp:latest'); +}); + +it('does not inject image tag when there is no commit', function () { + $service = [ + 'build' => './app', + ]; + + $hasBuild = data_get($service, 'build') !== null; + $hasImage = data_get($service, 'image') !== null; + $commit = null; + + expect($hasBuild)->toBeTrue(); + expect($hasImage)->toBeFalse(); + + // The condition should NOT trigger (no commit) + $shouldInject = $hasBuild && ! $hasImage && $commit; + expect($shouldInject)->toBeFalse(); +}); + +it('does not inject image tag for services without build directive', function () { + // Service that pulls a pre-built image + $service = [ + 'image' => 'nginx:alpine', + ]; + + $hasBuild = data_get($service, 'build') !== null; + $hasImage = data_get($service, 'image') !== null; + $commit = 'abc123def456'; + + expect($hasBuild)->toBeFalse(); + expect($hasImage)->toBeTrue(); + + // The condition should NOT trigger (no build) + $shouldInject = $hasBuild && ! $hasImage && $commit; + expect($shouldInject)->toBeFalse(); +}); + +it('uses pr-{id} tag for pull request deployments', function () { + $service = [ + 'build' => './app', + ]; + + $hasBuild = data_get($service, 'build') !== null; + $hasImage = data_get($service, 'image') !== null; + $commit = 'abc123def456'; + $uuid = 'app-uuid'; + $serviceName = 'web'; + $isPullRequest = true; + $pullRequestId = 42; + + // Simulate the PR image injection logic + if ($hasBuild && ! $hasImage && $commit) { + $imageTag = str($commit)->substr(0, 128)->value(); + if ($isPullRequest) { + $imageTag = "pr-{$pullRequestId}"; + } + $imageRepo = "{$uuid}_{$serviceName}"; + $service['image'] = "{$imageRepo}:{$imageTag}"; + } + + expect($service['image'])->toBe('app-uuid_web:pr-42'); +}); + +it('truncates commit SHA to 128 characters', function () { + $service = [ + 'build' => './app', + ]; + + $hasBuild = data_get($service, 'build') !== null; + $hasImage = data_get($service, 'image') !== null; + // Create a very long commit string + $commit = str_repeat('a', 200); + $uuid = 'app-uuid'; + $serviceName = 'web'; + + if ($hasBuild && ! $hasImage && $commit) { + $imageTag = str($commit)->substr(0, 128)->value(); + $imageRepo = "{$uuid}_{$serviceName}"; + $service['image'] = "{$imageRepo}:{$imageTag}"; + } + + // Tag should be exactly 128 characters + $parts = explode(':', $service['image']); + expect(strlen($parts[1]))->toBe(128); +}); + +it('handles multiple services with build directives', function () { + $services = [ + 'web' => ['build' => './web'], + 'worker' => ['build' => './worker'], + 'api' => ['build' => './api', 'image' => 'custom:tag'], // Has explicit image + 'redis' => ['image' => 'redis:alpine'], // No build + ]; + + $commit = 'abc123'; + $uuid = 'app-uuid'; + + foreach ($services as $serviceName => $service) { + $hasBuild = data_get($service, 'build') !== null; + $hasImage = data_get($service, 'image') !== null; + + if ($hasBuild && ! $hasImage && $commit) { + $imageTag = str($commit)->substr(0, 128)->value(); + $imageRepo = "{$uuid}_{$serviceName}"; + $services[$serviceName]['image'] = "{$imageRepo}:{$imageTag}"; + } + } + + // web and worker should get injected images + expect($services['web']['image'])->toBe('app-uuid_web:abc123'); + expect($services['worker']['image'])->toBe('app-uuid_worker:abc123'); + + // api keeps its custom image (has explicit image) + expect($services['api']['image'])->toBe('custom:tag'); + + // redis keeps its image (no build directive) + expect($services['redis']['image'])->toBe('redis:alpine'); +}); From 0cc59739015732baa25593860164c90eae0d0bef Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Fri, 5 Dec 2025 11:48:42 +0100 Subject: [PATCH 4/6] Disable rollback for non-commit image tags (e.g., 'latest') MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Existing Docker Compose deployments may have 'latest' or custom tags that aren't valid git commit SHAs. When rollback is triggered with these tags, the deployment fails because the system tries to use the tag as a git commit reference. This change: - Detects if image tag is a valid commit SHA or PR tag - Disables rollback button for non-commit tags with helpful tooltip - Displays appropriate label (SHA/PR/Tag) based on tag type - Guides users to re-deploy to create rollback-enabled images 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../project/application/rollback.blade.php | 27 ++++++++++++++----- 1 file changed, 21 insertions(+), 6 deletions(-) diff --git a/resources/views/livewire/project/application/rollback.blade.php b/resources/views/livewire/project/application/rollback.blade.php index 02f7d14b2..07ca40c56 100644 --- a/resources/views/livewire/project/application/rollback.blade.php +++ b/resources/views/livewire/project/application/rollback.blade.php @@ -21,18 +21,29 @@
+ @php + $tag = data_get($image, 'tag'); + $date = data_get($image, 'created_at'); + $interval = \Illuminate\Support\Carbon::parse($date); + // Check if tag looks like a commit SHA (hex string) or PR tag (pr-N) + $isCommitSha = preg_match('/^[0-9a-f]{7,128}$/i', $tag); + $isPrTag = preg_match('/^pr-\d+$/', $tag); + $isRollbackable = $isCommitSha || $isPrTag; + @endphp
@if (data_get($image, 'is_current')) LIVE | @endif - SHA: {{ data_get($image, 'tag') }} + @if ($isCommitSha) + SHA: {{ $tag }} + @elseif ($isPrTag) + PR: {{ $tag }} + @else + Tag: {{ $tag }} + @endif
- @php - $date = data_get($image, 'created_at'); - $interval = \Illuminate\Support\Carbon::parse($date); - @endphp
{{ $interval->diffForHumans() }}
{{ $date }}
@@ -42,9 +53,13 @@ class="bg-white border rounded-sm dark:border-coolgray-300 dark:bg-coolgray-100 Rollback + @elseif (!$isRollbackable) + + Rollback + @else + wire:click="rollbackImage('{{ $tag }}')"> Rollback @endif From 511415770a43389391802a9d5f7e284624e9b738 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Fri, 5 Dec 2025 12:22:20 +0100 Subject: [PATCH 5/6] Add server-level toggle to disable application image retention MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a new server-level setting that allows administrators to disable per-application image retention globally for all applications on a server. When enabled, Docker cleanup will only keep the currently running image regardless of individual application retention settings. Changes: - Add migration for disable_application_image_retention boolean field - Update ServerSetting model with cast - Add checkbox in DockerCleanup page (Advanced section) - Modify CleanupDocker action to check server-level setting - Update Rollback page to show warning and disable inputs when server retention is disabled - Add helper text noting server-level override capability 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- app/Actions/Server/CleanupDocker.php | 4 +++- app/Livewire/Project/Application/Rollback.php | 4 ++++ app/Livewire/Server/DockerCleanup.php | 5 +++++ app/Models/ServerSetting.php | 1 + ...ion_image_retention_to_server_settings.php | 22 +++++++++++++++++++ .../project/application/rollback.blade.php | 12 +++++++--- .../livewire/server/docker-cleanup.blade.php | 4 ++++ 7 files changed, 48 insertions(+), 4 deletions(-) create mode 100644 database/migrations/2025_12_05_100000_add_disable_application_image_retention_to_server_settings.php diff --git a/app/Actions/Server/CleanupDocker.php b/app/Actions/Server/CleanupDocker.php index cdbe10427..d9b4b5a6b 100644 --- a/app/Actions/Server/CleanupDocker.php +++ b/app/Actions/Server/CleanupDocker.php @@ -115,8 +115,10 @@ private function cleanupApplicationImages(Server $server, $applications = null): $applications = $server->applications(); } + $disableRetention = $server->settings->disable_application_image_retention ?? false; + foreach ($applications as $application) { - $imagesToKeep = $application->settings->docker_images_to_keep ?? 2; + $imagesToKeep = $disableRetention ? 0 : ($application->settings->docker_images_to_keep ?? 2); $imageRepository = $application->docker_registry_image_name ?? $application->uuid; // Get the currently running image tag diff --git a/app/Livewire/Project/Application/Rollback.php b/app/Livewire/Project/Application/Rollback.php index e3223a499..d6b490c79 100644 --- a/app/Livewire/Project/Application/Rollback.php +++ b/app/Livewire/Project/Application/Rollback.php @@ -23,10 +23,14 @@ class Rollback extends Component #[Validate(['integer', 'min:0', 'max:100'])] public int $dockerImagesToKeep = 2; + public bool $serverRetentionDisabled = false; + public function mount() { $this->parameters = get_route_parameters(); $this->dockerImagesToKeep = $this->application->settings->docker_images_to_keep ?? 2; + $server = $this->application->destination->server; + $this->serverRetentionDisabled = $server->settings->disable_application_image_retention ?? false; } public function saveSettings() diff --git a/app/Livewire/Server/DockerCleanup.php b/app/Livewire/Server/DockerCleanup.php index 764e583cd..92094c950 100644 --- a/app/Livewire/Server/DockerCleanup.php +++ b/app/Livewire/Server/DockerCleanup.php @@ -31,6 +31,9 @@ class DockerCleanup extends Component #[Validate('boolean')] public bool $deleteUnusedNetworks = false; + #[Validate('boolean')] + public bool $disableApplicationImageRetention = false; + public function mount(string $server_uuid) { try { @@ -52,6 +55,7 @@ public function syncData(bool $toModel = false) $this->server->settings->docker_cleanup_threshold = $this->dockerCleanupThreshold; $this->server->settings->delete_unused_volumes = $this->deleteUnusedVolumes; $this->server->settings->delete_unused_networks = $this->deleteUnusedNetworks; + $this->server->settings->disable_application_image_retention = $this->disableApplicationImageRetention; $this->server->settings->save(); } else { $this->forceDockerCleanup = $this->server->settings->force_docker_cleanup; @@ -59,6 +63,7 @@ public function syncData(bool $toModel = false) $this->dockerCleanupThreshold = $this->server->settings->docker_cleanup_threshold; $this->deleteUnusedVolumes = $this->server->settings->delete_unused_volumes; $this->deleteUnusedNetworks = $this->server->settings->delete_unused_networks; + $this->disableApplicationImageRetention = $this->server->settings->disable_application_image_retention; } } diff --git a/app/Models/ServerSetting.php b/app/Models/ServerSetting.php index 6da4dd4c6..4b33df300 100644 --- a/app/Models/ServerSetting.php +++ b/app/Models/ServerSetting.php @@ -61,6 +61,7 @@ class ServerSetting extends Model 'is_reachable' => 'boolean', 'is_usable' => 'boolean', 'is_terminal_enabled' => 'boolean', + 'disable_application_image_retention' => 'boolean', ]; protected static function booted() diff --git a/database/migrations/2025_12_05_100000_add_disable_application_image_retention_to_server_settings.php b/database/migrations/2025_12_05_100000_add_disable_application_image_retention_to_server_settings.php new file mode 100644 index 000000000..a2433e5c9 --- /dev/null +++ b/database/migrations/2025_12_05_100000_add_disable_application_image_retention_to_server_settings.php @@ -0,0 +1,22 @@ +boolean('disable_application_image_retention')->default(false); + }); + } + + public function down(): void + { + Schema::table('server_settings', function (Blueprint $table) { + $table->dropColumn('disable_application_image_retention'); + }); + } +}; diff --git a/resources/views/livewire/project/application/rollback.blade.php b/resources/views/livewire/project/application/rollback.blade.php index 07ca40c56..476712842 100644 --- a/resources/views/livewire/project/application/rollback.blade.php +++ b/resources/views/livewire/project/application/rollback.blade.php @@ -7,12 +7,18 @@
You can easily rollback to a previously built (local) images quickly.
+ @if($serverRetentionDisabled) + + Image retention is disabled at the server level. This setting has no effect until the server administrator enables it. + + @endif +
- Save + helper="Number of Docker images to keep for rollback during cleanup. Set to 0 to only keep the currently running image. PR images are always deleted during cleanup.

Note: Server administrators can disable image retention at the server level, which overrides this setting." + canGate="update" :canResource="$application" :disabled="$serverRetentionDisabled" /> + Save
diff --git a/resources/views/livewire/server/docker-cleanup.blade.php b/resources/views/livewire/server/docker-cleanup.blade.php index 8e96bc963..251137fa7 100644 --- a/resources/views/livewire/server/docker-cleanup.blade.php +++ b/resources/views/livewire/server/docker-cleanup.blade.php @@ -78,6 +78,10 @@
  • Networks not attached to running containers will be permanently deleted (networks used by stopped containers are affected).
  • Containers may lose connectivity if required networks are removed.
  • " /> +
    From 62aa7397dab64d8d25b16ab93badb354041c62c3 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Fri, 5 Dec 2025 12:57:57 +0100 Subject: [PATCH 6/6] Fix grep regex escaping for extended regex (ERE) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace preg_quote() with proper ERE escaping since grep -E uses extended regex syntax, not PHP/PCRE. This ensures special characters in registry URLs (dots, etc.) are properly escaped. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- app/Actions/Server/CleanupDocker.php | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/app/Actions/Server/CleanupDocker.php b/app/Actions/Server/CleanupDocker.php index d9b4b5a6b..076f7d0c5 100644 --- a/app/Actions/Server/CleanupDocker.php +++ b/app/Actions/Server/CleanupDocker.php @@ -89,8 +89,9 @@ private function buildImagePruneCommand($applicationImageRepos): string } 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, '/'); + // Escape special characters for grep extended regex (ERE) + // ERE special chars: . \ + * ? [ ^ ] $ ( ) { } | + return preg_replace('/([.\\\\+*?\[\]^$(){}|])/', '\\\\$1', $repo); })->implode('|'); // Delete unused images that: