diff --git a/app/Actions/Application/GetRollbackImages.php b/app/Actions/Application/GetRollbackImages.php new file mode 100644 index 0000000000..73ab3931fa --- /dev/null +++ b/app/Actions/Application/GetRollbackImages.php @@ -0,0 +1,123 @@ +} + */ + 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 `:` 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 + */ + 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; + } +} diff --git a/app/Http/Controllers/Api/ApplicationsController.php b/app/Http/Controllers/Api/ApplicationsController.php index 3cd02d31ec..f829c04d88 100644 --- a/app/Http/Controllers/Api/ApplicationsController.php +++ b/app/Http/Controllers/Api/ApplicationsController.php @@ -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( diff --git a/app/Jobs/ApplicationDeploymentJob.php b/app/Jobs/ApplicationDeploymentJob.php index 9fa8621fb1..acc4d3e7b6 100644 --- a/app/Jobs/ApplicationDeploymentJob.php +++ b/app/Jobs/ApplicationDeploymentJob.php @@ -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, diff --git a/app/Livewire/Project/Application/Rollback.php b/app/Livewire/Project/Application/Rollback.php index f6a8e386b4..6e54ac3d38 100644 --- a/app/Livewire/Project/Application/Rollback.php +++ b/app/Livewire/Project/Application/Rollback.php @@ -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 []; diff --git a/openapi.json b/openapi.json index 07caa78eb6..8e3bda3333 100644 --- a/openapi.json +++ b/openapi.json @@ -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" } diff --git a/openapi.yaml b/openapi.yaml index eebac91795..1dbe1c185c 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -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: diff --git a/resources/views/livewire/project/application/rollback.blade.php b/resources/views/livewire/project/application/rollback.blade.php index c159af392c..9a32c1a411 100644 --- a/resources/views/livewire/project/application/rollback.blade.php +++ b/resources/views/livewire/project/application/rollback.blade.php @@ -22,7 +22,7 @@ + helper="Rollback uses an existing image without rebuilding the application. Images in the Docker registry are pulled to the server first." flush> @can('view', $application) @@ -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
@@ -58,9 +59,12 @@ @elseif (!$isRollbackable) @endif + @if ($isRegistryImage) + + @endif

- Built {{ $createdAt->diffForHumans() }} + {{ $isRegistryImage ? 'Deployed' : 'Built' }} {{ $createdAt->diffForHumans() }} · {{ $date }}

@@ -84,7 +88,7 @@ @empty @endforelse diff --git a/tests/Feature/Api/ApplicationRollbackImagesApiTest.php b/tests/Feature/Api/ApplicationRollbackImagesApiTest.php index 4e61000ba7..ac94113353 100644 --- a/tests/Feature/Api/ApplicationRollbackImagesApiTest.php +++ b/tests/Feature/Api/ApplicationRollbackImagesApiTest.php @@ -1,21 +1,13 @@ 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(); }); diff --git a/tests/Feature/GetRollbackImagesTest.php b/tests/Feature/GetRollbackImagesTest.php new file mode 100644 index 0000000000..a9937a70ac --- /dev/null +++ b/tests/Feature/GetRollbackImagesTest.php @@ -0,0 +1,82 @@ +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']], +]);