Files
coolify/app/Actions/Application/GetRollbackImages.php
T

165 lines
6.4 KiB
PHP

<?php
namespace App\Actions\Application;
use App\Enums\ApplicationDeploymentStatus;
use App\Models\Application;
use App\Models\Server;
use Lorisleiva\Actions\Concerns\AsAction;
/**
* 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
{
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()) {
$repositories = self::imageRepositories($application);
$current = self::currentTag($server, $application, $repositories);
$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) => 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');
}
foreach ($this->registryImages($application, $current) as $image) {
if (! $images->has($image['tag'])) {
$images->put($image['tag'], $image);
}
}
return [
'current' => $current,
'images' => $images->values()->all(),
];
}
/**
* 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 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) => (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;
}
/**
* 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;
}
}