diff --git a/app/Actions/Server/CleanupDocker.php b/app/Actions/Server/CleanupDocker.php index 6bf094c32..076f7d0c5 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,122 @@ 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 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"; + } + + return implode(' && ', $commands); + } + + private function cleanupApplicationImages(Server $server, $applications = null): array + { + $cleanupLog = []; + + if ($applications === null) { + $applications = $server->applications(); + } + + $disableRetention = $server->settings->disable_application_image_retention ?? false; + + foreach ($applications as $application) { + $imagesToKeep = $disableRetention ? 0 : ($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 + // 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)) { + 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/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/Livewire/Project/Application/Rollback.php b/app/Livewire/Project/Application/Rollback.php index da67a5707..d6b490c79 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,30 @@ class Rollback extends Component public array $parameters; + #[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() + { + 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 +88,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/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/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/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/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/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/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/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 e0b1465dc..476712842 100644 --- a/resources/views/livewire/project/application/rollback.blade.php +++ b/resources/views/livewire/project/application/rollback.blade.php @@ -5,25 +5,51 @@ 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.
+ + @if($serverRetentionDisabled) + + Image retention is disabled at the server level. This setting has no effect until the server administrator enables it. + + @endif + +
+
+ + Save + +
@forelse ($images as $image)
-
+
+ @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 }}
@@ -33,9 +59,13 @@ Rollback + @elseif (!$isRollbackable) + + Rollback + @else + wire:click="rollbackImage('{{ $tag }}')"> Rollback @endif @@ -49,4 +79,4 @@
Loading available docker images...
-
+
\ No newline at end of file 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.
  • " /> +
    diff --git a/tests/Unit/Actions/Server/CleanupDockerTest.php b/tests/Unit/Actions/Server/CleanupDockerTest.php new file mode 100644 index 000000000..ebf73da06 --- /dev/null +++ b/tests/Unit/Actions/Server/CleanupDockerTest.php @@ -0,0 +1,253 @@ + '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); +}); + +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"); + } +}); 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'); +});