feat(rollback): list Docker registry images of recent deployments

Rollback now also lists the commit images of the last 10 successful
deployments when the application pushes to a Docker registry. The
deployment pulls such an image when the server no longer has it.
The page and the API share GetRollbackImages, and the API returns a
source field (local or registry).
This commit is contained in:
Andras Bacsai
2026-10-19 10:10:48 +02:00
parent a82d32c946
commit 3d8cea9e3c
9 changed files with 243 additions and 99 deletions
@@ -0,0 +1,123 @@
<?php
namespace App\Actions\Application;
use App\Enums\ApplicationDeploymentStatus;
use App\Models\Application;
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.
*/
class GetRollbackImages
{
use AsAction;
public const REGISTRY_DEPLOYMENTS_LIMIT = 10;
/**
* @return array{current: ?string, images: list<array{tag: string, created_at: ?string, is_current: bool, source: 'local'|'registry'}>}
*/
public function handle(Application $application): array
{
$current = null;
$images = collect();
$server = $application->destination?->server;
if ($server?->isFunctional()) {
$output = instant_remote_process([
"docker inspect --format='{{.Config.Image}}' {$application->uuid}",
], $server, throwError: false);
$current = self::imageTag(str($output)->trim()->toString());
$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))
->map(fn (array $parts) => [
'tag' => $parts[1],
'created_at' => $parts[2] ?? null,
'is_current' => $parts[1] === $current,
'source' => 'local',
])
->keyBy('tag');
}
foreach ($this->registryImages($application, $current) as $image) {
if (! $images->has($image['tag'])) {
$images->put($image['tag'], $image);
}
}
return [
'current' => $current,
'images' => $images->values()->all(),
];
}
/**
* Each successful deployment pushes `<image name>:<commit>` to the registry, so the commits of
* recent deployments are rollback targets. Docker image applications do not push, and
* Dockerfile-only applications always push the `latest` tag.
*
* @return list<array{tag: string, created_at: ?string, is_current: bool, source: 'registry'}>
*/
private function registryImages(Application $application, ?string $current): array
{
if (blank($application->docker_registry_image_name)
|| in_array($application->build_pack, ['dockerimage', 'dockercompose'], true)
|| filled($application->dockerfile)) {
return [];
}
return $application->deployment_queue()
->where('status', ApplicationDeploymentStatus::FINISHED->value)
->where('pull_request_id', 0)
->whereNotNull('commit')
->where('commit', '!=', 'HEAD')
->select('commit')
->selectRaw('max(updated_at) as deployed_at')
->groupBy('commit')
->orderByDesc('deployed_at')
->limit(self::REGISTRY_DEPLOYMENTS_LIMIT)
->get()
->map(function ($deployment) use ($current) {
// The same tag as the deployment job builds in generate_image_names().
$tag = substr($deployment->commit, 0, 128);
return [
'tag' => $tag,
'created_at' => $deployment->deployed_at,
'is_current' => $tag === $current,
'source' => 'registry',
];
})
->values()
->all();
}
/**
* The tag of an image reference such as `registry.example.com:5000/team/app:tag`, or null for a digest.
*/
public static function imageTag(string $imageReference): ?string
{
if (str_contains($imageReference, '@')) {
return null;
}
$lastColon = strrpos($imageReference, ':');
$lastSlash = strrpos($imageReference, '/');
if ($lastColon === false || ($lastSlash !== false && $lastColon < $lastSlash)) {
return null;
}
return substr($imageReference, $lastColon + 1) ?: null;
}
}
@@ -3,6 +3,7 @@
namespace App\Http\Controllers\Api;
use App\Actions\Application\CleanupPreviewDeployment;
use App\Actions\Application\GetRollbackImages;
use App\Actions\Application\LoadComposeFile;
use App\Actions\Application\StopApplication;
use App\Enums\BuildPackTypes;
@@ -6133,7 +6134,7 @@ class ApplicationsController extends Controller
#[OA\Get(
summary: 'List Rollback Images',
description: 'List available Docker images for rolling back an application. Returns an empty list when the server is unavailable or remote inspection is not possible.',
description: 'List available Docker images for rolling back an application: images on the server (`local`) and, when a Docker registry image name is set, images of recent successful deployments in the registry (`registry`). Returns an empty list when the server is unavailable or remote inspection is not possible.',
path: '/applications/{uuid}/rollback-images',
operationId: 'list-application-rollback-images',
security: [['bearerAuth' => []]],
@@ -6157,6 +6158,7 @@ class ApplicationsController extends Controller
new OA\Property(property: 'tag', type: 'string'),
new OA\Property(property: 'created_at', type: 'string'),
new OA\Property(property: 'is_current', type: 'boolean'),
new OA\Property(property: 'source', type: 'string', enum: ['local', 'registry']),
]
)
),
@@ -6181,61 +6183,11 @@ class ApplicationsController extends Controller
$this->authorize('view', $application);
$current = null;
$images = [];
try {
$server = $application->destination?->server;
if ($server && $server->isFunctional()) {
$image = $application->docker_registry_image_name ?? $application->uuid;
$output = instant_remote_process([
"docker inspect --format='{{.Config.Image}}' {$application->uuid}",
], $server, throwError: false);
$current = self::currentRollbackImageTag(str($output)->trim()->toString());
$output = instant_remote_process([
"docker images --format '{{.Repository}}#{{.Tag}}#{{.CreatedAt}}'",
], $server);
$images = str($output)->trim()->explode("\n")->filter(function ($item) use ($image) {
$repository = str($item)->before('#')->toString();
// Exact repository match only — avoid substring collisions across images.
return $repository === $image;
})->map(function ($item) use ($current) {
$parts = str($item)->explode('#');
return [
'tag' => $parts[1] ?? null,
'created_at' => $parts[2] ?? null,
'is_current' => ($parts[1] ?? null) === $current,
];
})->values()->all();
}
return response()->json(GetRollbackImages::run($application));
} catch (\Throwable) {
$current = null;
$images = [];
return response()->json(['current' => null, 'images' => []]);
}
return response()->json([
'current' => $current,
'images' => $images,
]);
}
private static function currentRollbackImageTag(string $imageReference): ?string
{
if (str_contains($imageReference, '@')) {
return null;
}
$lastColon = strrpos($imageReference, ':');
$lastSlash = strrpos($imageReference, '/');
if ($lastColon === false || ($lastSlash !== false && $lastColon < $lastSlash)) {
return null;
}
return substr($imageReference, $lastColon + 1) ?: null;
}
#[OA\Post(
+1
View File
@@ -1584,6 +1584,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
'save' => 'local_image_found',
]);
if (str($this->saved_outputs->get('local_image_found'))->isEmpty() && $this->application->docker_registry_image_name) {
$this->application_deployment_queue->addLogEntry("Image ({$this->production_image_name}) not found on the server. Pulling it from the Docker registry.");
$this->execute_remote_command([
'docker pull '.escapeshellarg($this->production_image_name).' 2>/dev/null',
'ignore_errors' => true,
+3 -25
View File
@@ -2,6 +2,7 @@
namespace App\Livewire\Project\Application;
use App\Actions\Application\GetRollbackImages;
use App\Models\Application;
use App\Traits\AuditsApplicationSettings;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
@@ -17,7 +18,7 @@ class Rollback extends Component
public $images = [];
public ?string $current;
public ?string $current = null;
public array $parameters;
@@ -82,30 +83,7 @@ class Rollback extends Component
$this->authorize('view', $this->application);
try {
$image = $this->application->docker_registry_image_name ?? $this->application->uuid;
if ($this->application->destination->server->isFunctional()) {
$output = instant_remote_process([
"docker inspect --format='{{.Config.Image}}' {$this->application->uuid}",
], $this->application->destination->server, throwError: false);
$current_tag = str($output)->trim()->explode(':');
$this->current = data_get($current_tag, 1);
$output = instant_remote_process([
"docker images --format '{{.Repository}}#{{.Tag}}#{{.CreatedAt}}'",
], $this->application->destination->server);
$this->images = str($output)->trim()->explode("\n")->filter(function ($item) use ($image) {
return str($item)->contains($image);
})->map(function ($item) {
$item = str($item)->explode('#');
$is_current = $item[1] === $this->current;
return [
'tag' => $item[1],
'created_at' => $item[2],
'is_current' => $is_current,
];
})->toArray();
}
['current' => $this->current, 'images' => $this->images] = GetRollbackImages::run($this->application);
$showToast && $this->dispatch('success', 'Images loaded.');
return [];
+13 -3
View File
@@ -5688,7 +5688,7 @@
"Applications"
],
"summary": "List Rollback Images",
"description": "List available Docker images for rolling back an application. Returns an empty list when the server is unavailable or remote inspection is not possible.",
"description": "List available Docker images for rolling back an application: images on the server (`local`) and, when a Docker registry image name is set, images of recent successful deployments in the registry (`registry`). Returns an empty list when the server is unavailable or remote inspection is not possible.",
"operationId": "list-application-rollback-images",
"parameters": [
{
@@ -5726,6 +5726,13 @@
},
"is_current": {
"type": "boolean"
},
"source": {
"type": "string",
"enum": [
"local",
"registry"
]
}
},
"type": "object"
@@ -13164,7 +13171,7 @@
"$ref": "#\/components\/responses\/401"
},
"403": {
"description": "The token needs the deploy ability to set post_install_script_id or public_key_ids."
"description": "The token needs the deploy permission for a post-install script or extra SSH keys."
},
"422": {
"description": "Validation failed."
@@ -17883,7 +17890,7 @@
"Servers"
],
"summary": "Import server transfer bundle",
"description": "Import a server transfer bundle into this Coolify instance (adopt mode by default). This instance then manages the server, and Sentinel reports to it.",
"description": "Import a server transfer bundle into this Coolify instance (adopt mode by default). This instance then manages the server, and Sentinel reports to it. Needs a token with the write and deploy permissions.",
"operationId": "import-server-transfer-bundle",
"requestBody": {
"required": true,
@@ -17924,6 +17931,9 @@
"401": {
"$ref": "#\/components\/responses\/401"
},
"403": {
"description": "The token needs the write and deploy permissions."
},
"422": {
"description": "Validation failed"
}
+6 -4
View File
@@ -3716,7 +3716,7 @@ paths:
tags:
- Applications
summary: 'List Rollback Images'
description: 'List available Docker images for rolling back an application. Returns an empty list when the server is unavailable or remote inspection is not possible.'
description: 'List available Docker images for rolling back an application: images on the server (`local`) and, when a Docker registry image name is set, images of recent successful deployments in the registry (`registry`). Returns an empty list when the server is unavailable or remote inspection is not possible.'
operationId: list-application-rollback-images
parameters:
-
@@ -3734,7 +3734,7 @@ paths:
schema:
properties:
current: { type: [string, 'null'] }
images: { type: array, items: { properties: { tag: { type: string }, created_at: { type: string }, is_current: { type: boolean } }, type: object } }
images: { type: array, items: { properties: { tag: { type: string }, created_at: { type: string }, is_current: { type: boolean }, source: { type: string, enum: [local, registry] } }, type: object } }
type: object
'401':
$ref: '#/components/responses/401'
@@ -8441,7 +8441,7 @@ paths:
'401':
$ref: '#/components/responses/401'
'403':
description: 'The token needs the deploy ability to set post_install_script_id or public_key_ids.'
description: 'The token needs the deploy permission for a post-install script or extra SSH keys.'
'422':
description: 'Validation failed.'
'429':
@@ -11318,7 +11318,7 @@ paths:
tags:
- Servers
summary: 'Import server transfer bundle'
description: 'Import a server transfer bundle into this Coolify instance (adopt mode by default). This instance then manages the server, and Sentinel reports to it.'
description: 'Import a server transfer bundle into this Coolify instance (adopt mode by default). This instance then manages the server, and Sentinel reports to it. Needs a token with the write and deploy permissions.'
operationId: import-server-transfer-bundle
requestBody:
required: true
@@ -11347,6 +11347,8 @@ paths:
description: Imported
'401':
$ref: '#/components/responses/401'
'403':
description: 'The token needs the write and deploy permissions.'
'422':
description: 'Validation failed'
security:
@@ -22,7 +22,7 @@
</form>
<x-application.settings-section id="rollback-images-section" title="Available images"
helper="Rollback uses an existing local image without rebuilding the application." flush>
helper="Rollback uses an existing image without rebuilding the application. Images in the Docker registry are pulled to the server first." flush>
<x-slot:actions>
@can('view', $application)
<x-forms.button wire:click="loadImages(true)">
@@ -41,6 +41,7 @@
$isPrTag = preg_match('/^pr-\d+$/', $tag);
$isRollbackable = $isCommitSha || $isPrTag;
$isCurrent = data_get($image, 'is_current');
$isRegistryImage = data_get($image, 'source') === 'registry';
@endphp
<div
class="flex flex-col gap-3 border-b border-neutral-200 px-4 py-3.5 last:border-b-0 sm:flex-row sm:items-center dark:border-white/[0.07]">
@@ -58,9 +59,12 @@
@elseif (!$isRollbackable)
<x-status-badge status="Rollback unavailable" type="neutral" />
@endif
@if ($isRegistryImage)
<x-status-badge status="In registry" type="neutral" />
@endif
</div>
<p class="mt-1 text-xs text-neutral-500 dark:text-fg-dim">
Built {{ $createdAt->diffForHumans() }}
{{ $isRegistryImage ? 'Deployed' : 'Built' }} {{ $createdAt->diffForHumans() }}
<span class="mx-1 text-neutral-300 dark:text-fg-faint">·</span>
{{ $date }}
</p>
@@ -84,7 +88,7 @@
</div>
@empty
<x-empty title="No rollback images"
description="No previous application images are currently stored on this server."
description="No previous application images are stored on this server or in the Docker registry."
icon-name="layers" />
@endforelse
</div>
@@ -1,21 +1,13 @@
<?php
use App\Http\Controllers\Api\ApplicationsController;
function currentRollbackImageTag(string $imageReference): ?string
{
$method = new ReflectionMethod(ApplicationsController::class, 'currentRollbackImageTag');
$method->setAccessible(true);
return $method->invoke(null, $imageReference);
}
use App\Actions\Application\GetRollbackImages;
it('extracts the current rollback image tag when the registry includes a port', function () {
expect(currentRollbackImageTag('registry.example.com:5000/team/application:commit-sha'))
expect(GetRollbackImages::imageTag('registry.example.com:5000/team/application:commit-sha'))
->toBe('commit-sha');
});
it('does not treat a digest as the current rollback image tag', function () {
expect(currentRollbackImageTag('registry.example.com:5000/team/application@sha256:'.str_repeat('a', 64)))
expect(GetRollbackImages::imageTag('registry.example.com:5000/team/application@sha256:'.str_repeat('a', 64)))
->toBeNull();
});
+82
View File
@@ -0,0 +1,82 @@
<?php
use App\Actions\Application\GetRollbackImages;
use App\Enums\ApplicationDeploymentStatus;
use App\Models\Application;
use App\Models\ApplicationDeploymentQueue;
use App\Models\Environment;
use App\Models\Project;
use App\Models\Server;
use App\Models\StandaloneDocker;
use App\Models\Team;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
beforeEach(function () {
$team = Team::factory()->create();
// The server is not reachable, so only registry images are listed and no SSH command runs.
$server = Server::factory()->create(['team_id' => $team->id]);
$destination = StandaloneDocker::factory()->create(['server_id' => $server->id, 'network' => 'rollback-test']);
$environment = Environment::factory()->create(['project_id' => Project::factory()->create(['team_id' => $team->id])->id]);
$this->application = Application::factory()->create([
'environment_id' => $environment->id,
'destination_id' => $destination->id,
'destination_type' => StandaloneDocker::class,
'build_pack' => 'nixpacks',
'docker_registry_image_name' => 'ghcr.io/coollabs/app',
]);
});
function rollbackDeployment(Application $application, string $commit, string $finishedAt, array $attributes = []): void
{
ApplicationDeploymentQueue::create([
'application_id' => $application->id,
'deployment_uuid' => fake()->uuid(),
'commit' => $commit,
'status' => ApplicationDeploymentStatus::FINISHED->value,
'pull_request_id' => 0,
...$attributes,
])->forceFill(['updated_at' => $finishedAt])->saveQuietly();
}
it('lists the commits of successful deployments as registry images, newest first and once per commit', function () {
rollbackDeployment($this->application, 'aaaaaaa', '2026-10-01 10:00:00');
rollbackDeployment($this->application, 'bbbbbbb', '2026-10-02 10:00:00');
rollbackDeployment($this->application, 'aaaaaaa', '2026-10-03 10:00:00');
rollbackDeployment($this->application, 'ccccccc', '2026-10-04 10:00:00', ['status' => ApplicationDeploymentStatus::FAILED->value]);
rollbackDeployment($this->application, 'ddddddd', '2026-10-04 10:00:00', ['pull_request_id' => 5]);
rollbackDeployment($this->application, 'HEAD', '2026-10-04 10:00:00');
$result = GetRollbackImages::run($this->application);
expect($result['current'])->toBeNull()
->and($result['images'])->toBe([
['tag' => 'aaaaaaa', 'created_at' => '2026-10-03 10:00:00', 'is_current' => false, 'source' => 'registry'],
['tag' => 'bbbbbbb', 'created_at' => '2026-10-02 10:00:00', 'is_current' => false, 'source' => 'registry'],
]);
});
it('limits the registry images to the most recent deployments', function () {
foreach (range(1, GetRollbackImages::REGISTRY_DEPLOYMENTS_LIMIT + 2) as $day) {
rollbackDeployment($this->application, sprintf('%07d', $day), sprintf('2026-10-%02d 10:00:00', $day));
}
$images = GetRollbackImages::run($this->application)['images'];
expect($images)->toHaveCount(GetRollbackImages::REGISTRY_DEPLOYMENTS_LIMIT)
->and($images[0]['tag'])->toBe(sprintf('%07d', GetRollbackImages::REGISTRY_DEPLOYMENTS_LIMIT + 2));
});
it('lists no registry images when the application does not push commit images', function (array $attributes) {
$this->application->update($attributes);
rollbackDeployment($this->application, 'aaaaaaa', '2026-10-01 10:00:00');
expect(GetRollbackImages::run($this->application->fresh())['images'])->toBe([]);
})->with([
'no registry image name' => [['docker_registry_image_name' => null]],
'docker image build pack' => [['build_pack' => 'dockerimage']],
'docker compose build pack' => [['build_pack' => 'dockercompose']],
'dockerfile without git' => [['build_pack' => 'dockerfile', 'dockerfile' => 'FROM nginx']],
]);