Add per-application Docker image retention for rollback (#7504)
This commit is contained in:
commit
21429a26b1
14 changed files with 696 additions and 21 deletions
|
|
@ -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 '<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();
|
||||
}
|
||||
|
||||
$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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 = [];
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -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('server_settings', function (Blueprint $table) {
|
||||
$table->boolean('disable_application_image_retention')->default(false);
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('server_settings', function (Blueprint $table) {
|
||||
$table->dropColumn('disable_application_image_retention');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -5,25 +5,51 @@
|
|||
<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>
|
||||
|
||||
@if($serverRetentionDisabled)
|
||||
<x-callout type="warning" class="mb-4">
|
||||
Image retention is disabled at the server level. This setting has no effect until the server administrator enables it.
|
||||
</x-callout>
|
||||
@endif
|
||||
|
||||
<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.<br><br><strong>Note:</strong> Server administrators can disable image retention at the server level, which overrides this setting."
|
||||
canGate="update" :canResource="$application" :disabled="$serverRetentionDisabled" />
|
||||
<x-forms.button canGate="update" :canResource="$application" type="submit" :disabled="$serverRetentionDisabled">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">
|
||||
@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
|
||||
<div class="p-2">
|
||||
<div class="">
|
||||
@if (data_get($image, 'is_current'))
|
||||
<span class="font-bold dark:text-warning">LIVE</span>
|
||||
|
|
||||
@endif
|
||||
SHA: {{ data_get($image, 'tag') }}
|
||||
@if ($isCommitSha)
|
||||
SHA: {{ $tag }}
|
||||
@elseif ($isPrTag)
|
||||
PR: {{ $tag }}
|
||||
@else
|
||||
Tag: {{ $tag }}
|
||||
@endif
|
||||
</div>
|
||||
@php
|
||||
$date = data_get($image, 'created_at');
|
||||
$interval = \Illuminate\Support\Carbon::parse($date);
|
||||
@endphp
|
||||
<div class="text-xs">{{ $interval->diffForHumans() }}</div>
|
||||
<div class="text-xs">{{ $date }}</div>
|
||||
</div>
|
||||
|
|
@ -33,9 +59,13 @@
|
|||
<x-forms.button disabled tooltip="This image is currently running.">
|
||||
Rollback
|
||||
</x-forms.button>
|
||||
@elseif (!$isRollbackable)
|
||||
<x-forms.button disabled tooltip="Rollback not available for '{{ $tag }}' tag. Only commit-based tags support rollback. Re-deploy to create a rollback-enabled image.">
|
||||
Rollback
|
||||
</x-forms.button>
|
||||
@else
|
||||
<x-forms.button class="dark:bg-coolgray-100"
|
||||
wire:click="rollbackImage('{{ data_get($image, 'tag') }}')">
|
||||
wire:click="rollbackImage('{{ $tag }}')">
|
||||
Rollback
|
||||
</x-forms.button>
|
||||
@endif
|
||||
|
|
@ -49,4 +79,4 @@
|
|||
</div>
|
||||
</div>
|
||||
<div wire:target='loadImages' wire:loading>Loading available docker images...</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -78,6 +78,10 @@
|
|||
<li>Networks not attached to running containers will be permanently deleted (networks used by stopped containers are affected).</li>
|
||||
<li>Containers may lose connectivity if required networks are removed.</li>
|
||||
</ul>" />
|
||||
<x-forms.checkbox canGate="update" :canResource="$server" instantSave
|
||||
id="disableApplicationImageRetention"
|
||||
label="Disable Application Image Retention"
|
||||
helper="When enabled, Docker cleanup will delete all old application images regardless of per-application retention settings. Only the currently running image will be kept.<br><br><strong>Warning: This disables rollback capabilities for all applications on this server.</strong>" />
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
|
|
|||
253
tests/Unit/Actions/Server/CleanupDockerTest.php
Normal file
253
tests/Unit/Actions/Server/CleanupDockerTest.php
Normal file
|
|
@ -0,0 +1,253 @@
|
|||
<?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);
|
||||
});
|
||||
|
||||
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");
|
||||
}
|
||||
});
|
||||
171
tests/Unit/Parsers/ApplicationParserImageTagTest.php
Normal file
171
tests/Unit/Parsers/ApplicationParserImageTagTest.php
Normal file
|
|
@ -0,0 +1,171 @@
|
|||
<?php
|
||||
|
||||
/**
|
||||
* Tests for Docker Compose image tag injection in applicationParser.
|
||||
*
|
||||
* These tests verify the logic for injecting commit-based image tags
|
||||
* into Docker Compose services with build directives.
|
||||
*/
|
||||
it('injects image tag for services with build but no image directive', function () {
|
||||
// Test the condition: hasBuild && !hasImage && commit
|
||||
$service = [
|
||||
'build' => './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');
|
||||
});
|
||||
Loading…
Reference in a new issue