Add per-application Docker image retention for rollback capability

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 <noreply@anthropic.com>
This commit is contained in:
Andras Bacsai 2025-12-05 11:02:07 +01:00
parent 558a885fdc
commit 4ed7a4238a
6 changed files with 390 additions and 11 deletions

View file

@ -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 '<none>' | ".
"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;
}
}

View file

@ -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();
}

View file

@ -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 = [];

View file

@ -0,0 +1,22 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('application_settings', function (Blueprint $table) {
$table->integer('docker_images_to_keep')->default(2);
});
}
public function down(): void
{
Schema::table('application_settings', function (Blueprint $table) {
$table->dropColumn('docker_images_to_keep');
});
}
};

View file

@ -5,13 +5,22 @@
<x-forms.button wire:click='loadImages(true)'>Reload Available Images</x-forms.button>
@endcan
</div>
<div class="pb-4 ">You can easily rollback to a previously built (local) images
quickly.</div>
<div class="pb-4">You can easily rollback to a previously built (local) images quickly.</div>
<div class="pb-4">
<form wire:submit="saveSettings" class="flex items-end gap-2 w-96">
<x-forms.input id="dockerImagesToKeep" type="number" min="0" max="100" label="Images to keep for rollback"
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."
canGate="update" :canResource="$application" />
<x-forms.button canGate="update" :canResource="$application" type="submit">Save</x-forms.button>
</form>
</div>
<div wire:target='loadImages' wire:loading.remove>
<div class="flex flex-wrap">
@forelse ($images as $image)
<div class="w-2/4 p-2">
<div class="bg-white border rounded-sm dark:border-coolgray-300 dark:bg-coolgray-100 border-neutral-200">
<div
class="bg-white border rounded-sm dark:border-coolgray-300 dark:bg-coolgray-100 border-neutral-200">
<div class="p-2">
<div class="">
@if (data_get($image, 'is_current'))
@ -49,4 +58,4 @@
</div>
</div>
<div wire:target='loadImages' wire:loading>Loading available docker images...</div>
</div>
</div>

View file

@ -0,0 +1,204 @@
<?php
beforeEach(function () {
Mockery::close();
});
afterEach(function () {
Mockery::close();
});
it('categorizes images correctly into PR and regular images', function () {
// Test the image categorization logic
// Build images (*-build) are excluded from retention and handled by docker image prune
$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' => '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);
});