fix(rollback): list the commit images of Docker Compose applications

Closes #12446

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andras BacsaiandClaude Opus 5.5 committed 2026-11-03 18:14:32 +01:00
1 parent 9c6138fe18
commit f057aaf2e6
3 files changed
+73 -13

No files matched your search

+1
View File
@@ -19,6 +19,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
### Fixed
- List the commit images of Docker Compose applications on the **Rollback** page and in `GET /api/v1/applications/{uuid}/rollback-images`. Since v4.5.0 the list was always empty, because a Compose build names each service image `<uuid>_<service>`. The page also shows the running commit when the Compose file has services from other images, for example a database ([#12446](https://github.com/coollabsio/coolify/issues/12446))
- Finish the startup and the nightly cleanup of stuck resources on instances with many large deployment logs. Before, the cleanup loaded the logs of all deployments and stopped with "Allowed memory size exhausted", so stuck deployments, tasks, and backups were not marked as failed and the proxy configuration was not set up ([#12435](https://github.com/coollabsio/coolify/issues/12435))
- Open the database backup pages and **Settings > Backup** faster when a backup has many executions. Before, these pages loaded all executions of the backup on each request, but they did not show them
- Queue a deployment from the MCP `deploy` tool and the MCP `control` start action while a deployment of the same application is in progress, as the API and the **Deploy** button do. Before, both started at once, so two rolling updates could remove the new container of each other and stop the application. A deployment of the same commit is now skipped, and the response gives the UUID of the deployment in progress. A rolling update now also removes only the containers that ran before it started ([#12442](https://github.com/coollabsio/coolify/issues/12442))
+39 -11
View File
@@ -8,9 +8,10 @@ use App\Models\Server;
use Lorisleiva\Actions\Concerns\AsAction;
/**
* Lists the images an application can roll back to: the images on its server, and the images of
* recent successful deployments that were pushed to the Docker registry. The deployment pulls a
* registry image again when the server no longer has it.
* Lists the images an application can roll back to: the images on its server (for Docker Compose,
* the commit images of its services), and the images of recent successful deployments that were
* pushed to the Docker registry. The deployment pulls a registry image again when the server no
* longer has it.
*/
class GetRollbackImages
{
@@ -28,22 +29,25 @@ class GetRollbackImages
$server = $application->destination?->server;
if ($server?->isFunctional()) {
$current = self::currentTag($server, $application);
$repositories = self::imageRepositories($application);
$current = self::currentTag($server, $application, $repositories);
$repository = $application->docker_registry_image_name ?: $application->uuid;
$output = instant_remote_process([
"docker images --format '{{.Repository}}#{{.Tag}}#{{.CreatedAt}}'",
], $server);
$images = str($output)->trim()->explode("\n")
->map(fn (string $line) => explode('#', $line))
// Exact repository match only, so other images with a similar name are not listed.
->filter(fn (array $parts) => $parts[0] === $repository && filled($parts[1] ?? null))
->filter(fn (array $parts) => in_array($parts[0], $repositories, true) && filled($parts[1] ?? null))
->map(fn (array $parts) => [
'tag' => $parts[1],
'created_at' => $parts[2] ?? null,
'is_current' => $parts[1] === $current,
'source' => 'local',
])
// The images of all Docker Compose services of a commit have the same tag. Docker
// lists the newest image first, so the newest image of each tag stays.
->unique('tag')
->keyBy('tag');
}
@@ -60,15 +64,39 @@ class GetRollbackImages
}
/**
* The tag of the running production containers. Container names can have a timestamp or a
* custom name, so find them by their owner labels. Null when no container runs, or when the
* running containers use different tags (for example during a rolling update).
* The repositories of the images that the application builds or pulls. A Docker Compose
* build names the image of each service `<uuid>_<service>`, like the Compose parser does.
*
* @return list<string>
*/
private static function currentTag(Server $server, Application $application): ?string
private static function imageRepositories(Application $application): array
{
if ($application->build_pack === 'dockercompose') {
return collect($application->composeServicesWithoutParse())
->keys()
->map(fn (int|string $service) => "{$application->uuid}_{$service}")
->all();
}
return [$application->docker_registry_image_name ?: $application->uuid];
}
/**
* The tag of the running production containers. Container names can have a timestamp or a
* custom name, so find them by their owner labels. Only containers of the application images
* count, so the image tags of other Docker Compose services (for example `postgres:16`) do not.
* Null when no container runs, or when the running containers use different tags (for example
* during a rolling update).
*
* @param list<string> $repositories
*/
private static function currentTag(Server $server, Application $application, array $repositories): ?string
{
$tags = getCurrentApplicationContainerStatus($server, $application, 0)
->filter(fn ($container) => data_get($container, 'State') === 'running')
->map(fn ($container) => self::imageTag((string) data_get($container, 'Image')))
->map(fn ($container) => (string) data_get($container, 'Image'))
->filter(fn (string $image) => collect($repositories)->contains(fn (string $repository) => str_starts_with($image, "{$repository}:")))
->map(fn (string $image) => self::imageTag($image))
->unique();
return $tags->count() === 1 ? $tags->first() : null;
+33 -2
View File
@@ -95,7 +95,7 @@ describe('on a reachable server', function () {
$this->application = $this->application->fresh();
});
function fakeRollbackServer(Application $application, array $containers): void
function fakeRollbackServer(Application $application, array $containers, ?array $images = null): void
{
$uuid = $application->uuid;
$ps = collect($containers)->map(fn (array $container) => json_encode([
@@ -105,7 +105,7 @@ describe('on a reachable server', function () {
'State' => $container['state'] ?? 'running',
'Labels' => "coolify.applicationUuid={$uuid},coolify.pullRequestId=".($container['pr'] ?? 0).',coolify.managed=true',
]))->implode(PHP_EOL);
$images = implode(PHP_EOL, ["{$uuid}#aaaaaaa#2026-10-01 10:00:00", "{$uuid}#bbbbbbb#2026-10-02 10:00:00"]);
$images = implode(PHP_EOL, $images ?? ["{$uuid}#aaaaaaa#2026-10-01 10:00:00", "{$uuid}#bbbbbbb#2026-10-02 10:00:00"]);
Process::fake(fn ($process) => Process::result(output: str_contains(remoteProcessScript($process), 'docker images') ? $images : $ps));
}
@@ -143,4 +143,35 @@ describe('on a reachable server', function () {
expect(GetRollbackImages::run($this->application)['current'])->toBeNull();
});
it('lists the commit images of Docker Compose services once per commit', function () {
$uuid = $this->application->uuid;
$this->application->update([
'build_pack' => 'dockercompose',
'docker_compose_raw' => "services:\n app:\n build: .\n worker:\n build: ./worker\n db:\n image: postgres:16\n",
]);
fakeRollbackServer($this->application, [
['name' => "app-{$uuid}", 'image' => "{$uuid}_app:bbbbbbb"],
['name' => "worker-{$uuid}", 'image' => "{$uuid}_worker:bbbbbbb"],
['name' => "db-{$uuid}", 'image' => 'postgres:16'],
], [
"{$uuid}_app#bbbbbbb#2026-10-02 10:00:05",
"{$uuid}_worker#bbbbbbb#2026-10-02 10:00:00",
"{$uuid}_app#aaaaaaa#2026-10-01 10:00:00",
"{$uuid}_worker#aaaaaaa#2026-10-01 10:00:00",
// Not a service of this application, the application image itself, or another application.
"{$uuid}_other#ccccccc#2026-10-03 10:00:00",
"{$uuid}#ddddddd#2026-10-03 10:00:00",
"{$uuid}x_app#eeeeeee#2026-10-03 10:00:00",
'postgres#16#2026-09-01 10:00:00',
]);
$result = GetRollbackImages::run($this->application->fresh());
expect($result['current'])->toBe('bbbbbbb')
->and($result['images'])->toBe([
['tag' => 'bbbbbbb', 'created_at' => '2026-10-02 10:00:05', 'is_current' => true, 'source' => 'local'],
['tag' => 'aaaaaaa', 'created_at' => '2026-10-01 10:00:00', 'is_current' => false, 'source' => 'local'],
]);
});
});