mirror of
https://github.com/tiennm99/coolify.git
synced 2026-04-17 15:20:40 +00:00
Add per-application Docker image retention for rollback (#7504)
This commit is contained in:
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');
|
||||
});
|
||||
Reference in New Issue
Block a user