mirror of
https://github.com/coollabsio/coolify.git
synced 2026-11-04 10:42:23 +00:00
The MCP deploy tool and control start skipped the deployment queue, so two rolling updates of one application could run at once and remove the new container of each other. A rolling update now also removes only the containers that ran before it started. Closes #12442 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
6639 lines
319 KiB
PHP
6639 lines
319 KiB
PHP
<?php
|
|
|
|
namespace App\Jobs;
|
|
|
|
use App\Actions\Docker\GetContainersStatus;
|
|
use App\Actions\Shared\EnsureContentFilesOnServer;
|
|
use App\Enums\ApplicationDeploymentStatus;
|
|
use App\Enums\ProcessStatus;
|
|
use App\Enums\StaticImageTypes;
|
|
use App\Events\ApplicationConfigurationChanged;
|
|
use App\Events\ServiceStatusChanged;
|
|
use App\Exceptions\DeploymentException;
|
|
use App\Models\Application;
|
|
use App\Models\ApplicationDeploymentQueue;
|
|
use App\Models\ApplicationPreview;
|
|
use App\Models\ApplicationSetting;
|
|
use App\Models\EnvironmentVariable;
|
|
use App\Models\GiteaApp;
|
|
use App\Models\GithubApp;
|
|
use App\Models\GitlabApp;
|
|
use App\Models\Server;
|
|
use App\Models\StandaloneDocker;
|
|
use App\Models\SwarmDocker;
|
|
use App\Notifications\Application\DeploymentFailed;
|
|
use App\Notifications\Application\DeploymentSuccess;
|
|
use App\Services\DeploymentConfiguration\ApplicationConfigurationSnapshot;
|
|
use App\Support\RemoteSecretReferences;
|
|
use App\Support\RemoteSecretValueFormatter;
|
|
use App\Support\ValidationPatterns;
|
|
use App\Traits\EnvironmentVariableAnalyzer;
|
|
use App\Traits\ExecuteRemoteCommand;
|
|
use Carbon\Carbon;
|
|
use Exception;
|
|
use Illuminate\Bus\Queueable;
|
|
use Illuminate\Contracts\Queue\ShouldBeEncrypted;
|
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
|
use Illuminate\Foundation\Bus\Dispatchable;
|
|
use Illuminate\Queue\InteractsWithQueue;
|
|
use Illuminate\Queue\SerializesModels;
|
|
use Illuminate\Queue\TimeoutExceededException;
|
|
use Illuminate\Support\Arr;
|
|
use Illuminate\Support\Collection;
|
|
use Illuminate\Support\Facades\Cache;
|
|
use Illuminate\Support\Sleep;
|
|
use Illuminate\Support\Str;
|
|
use JsonException;
|
|
use Spatie\Url\Url;
|
|
use Symfony\Component\Yaml\Yaml;
|
|
use Throwable;
|
|
|
|
class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
|
|
{
|
|
use Dispatchable, EnvironmentVariableAnalyzer, ExecuteRemoteCommand, InteractsWithQueue, Queueable, SerializesModels;
|
|
|
|
public const BUILD_TIME_ENV_PATH = '/artifacts/build-time.env';
|
|
|
|
private const BUILD_FINGERPRINT_LABEL = 'coolify.build-fingerprint';
|
|
|
|
public const BUILD_TIME_SHELL_ENV_PATH = '/artifacts/build-time-shell.env';
|
|
|
|
public const BUILD_TIME_ENV_LAUNCHER_PATH = '/artifacts/run-with-build-time-env';
|
|
|
|
private const BUILD_SCRIPT_PATH = '/artifacts/build.sh';
|
|
|
|
private const NIXPACKS_PLAN_PATH = '/artifacts/thegameplan.json';
|
|
|
|
private const RAILPACK_REPOSITORY_CONFIG_PATH = 'railpack.json';
|
|
|
|
private const RAILPACK_GENERATED_CONFIG_PATH = '.coolify/railpack.generated.json';
|
|
|
|
private const CONTAINER_REMOVE_TIMEOUT_MARKER = '__COOLIFY_CONTAINER_REMOVE_TIMEOUT__';
|
|
|
|
private const DOCKER_CLIENT_ENV_KEYS = [
|
|
'BUILDKIT_HOST',
|
|
'BUILDX_BUILDER',
|
|
'BUILDX_CONFIG',
|
|
'DOCKER_API_VERSION',
|
|
'DOCKER_BUILDKIT',
|
|
'DOCKER_CERT_PATH',
|
|
'DOCKER_CLI_EXPERIMENTAL',
|
|
'DOCKER_CONFIG',
|
|
'DOCKER_CONTEXT',
|
|
'DOCKER_HOST',
|
|
'DOCKER_TLS',
|
|
'DOCKER_TLS_VERIFY',
|
|
];
|
|
|
|
public $tries = 1;
|
|
|
|
public $timeout = 3600;
|
|
|
|
public static int $batch_counter = 0;
|
|
|
|
private ApplicationDeploymentQueue $application_deployment_queue;
|
|
|
|
private Application $application;
|
|
|
|
private string $deployment_uuid;
|
|
|
|
private int $pull_request_id;
|
|
|
|
private string $commit;
|
|
|
|
private bool $rollback;
|
|
|
|
private bool $force_rebuild;
|
|
|
|
private bool $restart_only;
|
|
|
|
private ?string $docker_compose_build_fingerprint = null;
|
|
|
|
private ?string $dockerImage = null;
|
|
|
|
private ?string $dockerImageTag = null;
|
|
|
|
private ?string $dockerImagePreviewTag = null;
|
|
|
|
private GithubApp|GitlabApp|GiteaApp|string $source = 'other';
|
|
|
|
private StandaloneDocker|SwarmDocker $destination;
|
|
|
|
// Deploy to Server
|
|
private Server $server;
|
|
|
|
// Build Server
|
|
private Server $build_server;
|
|
|
|
private bool $use_build_server = false;
|
|
|
|
private Server $mainServer;
|
|
|
|
private bool $is_this_additional_server = false;
|
|
|
|
private ?ApplicationPreview $preview = null;
|
|
|
|
private ?string $git_type = null;
|
|
|
|
private bool $only_this_server = false;
|
|
|
|
private string $container_name;
|
|
|
|
private ?string $currently_running_container_name = null;
|
|
|
|
/**
|
|
* Containers that ran before the rolling update started its new container. Only these are
|
|
* removed after it, so the new container of another deployment of the application stays.
|
|
*
|
|
* @var list<string>|null
|
|
*/
|
|
private ?array $containerNamesBeforeRollingUpdate = null;
|
|
|
|
private string $basedir;
|
|
|
|
private string $workdir;
|
|
|
|
private ?string $build_pack = null;
|
|
|
|
private string $configuration_dir;
|
|
|
|
private string $build_image_name;
|
|
|
|
private string $production_image_name;
|
|
|
|
private bool $is_debug_enabled;
|
|
|
|
private Collection|string $build_args;
|
|
|
|
private $env_args;
|
|
|
|
/** @var array<string, string>|null */
|
|
private ?array $remote_secrets_cache = null;
|
|
|
|
/** @var array<int, int>|null */
|
|
private ?array $previewVariableIdsWithProductionValues = null;
|
|
|
|
private ?bool $has_secret_manager_source = null;
|
|
|
|
private $env_nixpacks_args;
|
|
|
|
private $env_railpack_args;
|
|
|
|
private $docker_compose;
|
|
|
|
private ?string $generated_docker_compose = null;
|
|
|
|
private ?string $nixpacks_plan = null;
|
|
|
|
private Collection $nixpacks_plan_json;
|
|
|
|
private ?string $nixpacks_type = null;
|
|
|
|
private string $dockerfile_location = '/Dockerfile';
|
|
|
|
private string $docker_compose_location = '/docker-compose.yaml';
|
|
|
|
private ?string $docker_compose_custom_start_command = null;
|
|
|
|
private ?string $docker_compose_custom_build_command = null;
|
|
|
|
private ?string $addHosts = null;
|
|
|
|
private ?string $buildTarget = null;
|
|
|
|
private bool $disableBuildCache = false;
|
|
|
|
private Collection $saved_outputs;
|
|
|
|
private ?string $secrets_hash_key = null;
|
|
|
|
private ?string $full_healthcheck_url = null;
|
|
|
|
private string $serverUser = 'root';
|
|
|
|
private string $serverUserHomeDir = '/root';
|
|
|
|
private string $dockerConfigFileExists = 'NOK';
|
|
|
|
private int $customPort = 22;
|
|
|
|
private ?string $customRepository = null;
|
|
|
|
private ?string $fullRepoUrl = null;
|
|
|
|
private ?string $branch = null;
|
|
|
|
private ?string $coolify_variables = null;
|
|
|
|
private bool $preserveRepository = false;
|
|
|
|
private bool $dockerBuildkitSupported = false;
|
|
|
|
private bool $dockerBuildxAvailable = false;
|
|
|
|
private bool $dockerSecretsSupported = false;
|
|
|
|
private bool $useBuildtimeEnvironmentLauncher = false;
|
|
|
|
private bool $skip_build = false;
|
|
|
|
private Collection|string $build_secrets;
|
|
|
|
/**
|
|
* Build-time variables that the helper container has in its environment for build secrets.
|
|
*
|
|
* @var array<int, string>
|
|
*/
|
|
private array $helperBuildSecretEnvironmentKeys = [];
|
|
|
|
public function tags()
|
|
{
|
|
// Do not remove this one, it needs to properly identify which worker is running the job
|
|
return ['App\Models\ApplicationDeploymentQueue:'.$this->application_deployment_queue_id];
|
|
}
|
|
|
|
public function __construct(public int $application_deployment_queue_id)
|
|
{
|
|
$this->onQueue(deployment_queue());
|
|
|
|
$this->application_deployment_queue = ApplicationDeploymentQueue::find($this->application_deployment_queue_id);
|
|
$this->nixpacks_plan_json = collect([]);
|
|
|
|
$this->application = Application::find($this->application_deployment_queue->application_id);
|
|
$this->build_pack = data_get($this->application, 'build_pack');
|
|
$this->build_args = collect([]);
|
|
$this->build_secrets = '';
|
|
|
|
$this->deployment_uuid = $this->application_deployment_queue->deployment_uuid;
|
|
$this->pull_request_id = $this->application_deployment_queue->pull_request_id;
|
|
$this->commit = validateGitRef($this->application_deployment_queue->commit, 'deployment commit');
|
|
$this->rollback = $this->application_deployment_queue->rollback;
|
|
$this->disableBuildCache = $this->application->settings->disable_build_cache;
|
|
$this->force_rebuild = $this->application_deployment_queue->force_rebuild;
|
|
if ($this->disableBuildCache) {
|
|
$this->force_rebuild = true;
|
|
}
|
|
$this->restart_only = $this->application_deployment_queue->restart_only;
|
|
// These build packs need the cloned repository to start, so a restart runs a regular deployment.
|
|
// The deployment skips the build when the image for the commit exists.
|
|
$this->restart_only = $this->restart_only && ! in_array($this->application->build_pack, ['dockerimage', 'dockerfile', 'dockercompose'], true);
|
|
$this->only_this_server = $this->application_deployment_queue->only_this_server;
|
|
$this->dockerImagePreviewTag = $this->application_deployment_queue->docker_registry_image_tag;
|
|
$this->validateDockerRegistryImageConfiguration();
|
|
|
|
$this->git_type = data_get($this->application_deployment_queue, 'git_type');
|
|
|
|
$source = data_get($this->application, 'source');
|
|
if ($source) {
|
|
$this->source = $source->getMorphClass()::where('id', $this->application->source->id)->first();
|
|
// Share one source instance so the job reuses its in-memory GitHub installation token.
|
|
$this->application->setRelation('source', $this->source);
|
|
}
|
|
$this->server = Server::find($this->application_deployment_queue->server_id);
|
|
$this->timeout = $this->server->settings->dynamic_timeout;
|
|
$this->destination = $this->server->destinations()->where('id', $this->application_deployment_queue->destination_id)->first();
|
|
$this->server = $this->mainServer = $this->destination->server;
|
|
$this->serverUser = $this->server->user;
|
|
$this->is_this_additional_server = $this->application->additional_servers()->wherePivot('server_id', $this->server->id)->count() > 0;
|
|
$this->preserveRepository = $this->application->settings->is_preserve_repository_enabled;
|
|
|
|
$this->basedir = $this->application->generateBaseDir($this->deployment_uuid);
|
|
$baseDir = $this->application->base_directory;
|
|
if ($baseDir && $baseDir !== '/') {
|
|
$this->validatePathField($baseDir, 'base_directory');
|
|
}
|
|
$this->workdir = "{$this->basedir}".rtrim($baseDir, '/');
|
|
$this->configuration_dir = application_configuration_dir()."/{$this->application->uuid}";
|
|
$this->is_debug_enabled = $this->application->settings->is_debug_enabled;
|
|
|
|
$this->container_name = generateApplicationContainerName($this->application, $this->pull_request_id);
|
|
|
|
$this->saved_outputs = collect();
|
|
|
|
// Set preview fqdn
|
|
if ($this->pull_request_id !== 0) {
|
|
$this->preview = ApplicationPreview::findPreviewByApplicationAndPullId($this->application->id, $this->pull_request_id);
|
|
if ($this->application->build_pack === 'dockerimage' && str($this->dockerImagePreviewTag)->isEmpty()) {
|
|
$this->dockerImagePreviewTag = $this->preview?->docker_registry_image_tag;
|
|
}
|
|
if ($this->preview) {
|
|
if ($this->application->build_pack === 'dockercompose') {
|
|
$this->preview->generate_preview_fqdn_compose(keepExistingDomains: true);
|
|
} else {
|
|
$this->preview->generate_preview_fqdn(keepExistingDomains: true);
|
|
}
|
|
}
|
|
if ($this->application->isGithubAppSource()) {
|
|
ApplicationPullRequestUpdateJob::dispatch(application: $this->application, preview: $this->preview, deployment_uuid: $this->deployment_uuid, status: ProcessStatus::IN_PROGRESS);
|
|
}
|
|
if ($this->application->build_pack === 'dockerfile') {
|
|
if (data_get($this->application, 'dockerfile_location')) {
|
|
$this->dockerfile_location = $this->validatePathField($this->application->dockerfile_location, 'dockerfile_location');
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Build the --add-host flags for the containers on the destination network.
|
|
*
|
|
* Build helper containers are named after their deployment uuid and only live for the
|
|
* duration of that deployment. BuildKit keys every RUN layer on the --add-host set,
|
|
* so letting one in gives the next build a different set and forces a full rebuild.
|
|
*/
|
|
private function addHostFlags(Collection $containers): string
|
|
{
|
|
$containers = $containers->sort()->values();
|
|
$buildHelperNames = ApplicationDeploymentQueue::query()
|
|
->whereIn('deployment_uuid', $containers->pluck('Name')->filter())
|
|
->pluck('deployment_uuid');
|
|
|
|
$ips = collect([]);
|
|
foreach ($containers as $container) {
|
|
$containerName = data_get($container, 'Name');
|
|
if ($containerName === 'coolify-proxy') {
|
|
continue;
|
|
}
|
|
if (isGeneratedContainerName($containerName)) {
|
|
continue;
|
|
}
|
|
if ($buildHelperNames->contains($containerName)) {
|
|
continue;
|
|
}
|
|
$containerIp = data_get($container, 'IPv4Address');
|
|
if ($containerName && $containerIp) {
|
|
$ips->put($containerName, str($containerIp)->before('/')->value());
|
|
}
|
|
}
|
|
|
|
return $ips->map(function ($ip, $name) {
|
|
return "--add-host $name:$ip";
|
|
})->implode(' ');
|
|
}
|
|
|
|
public function handle(): void
|
|
{
|
|
// Check if deployment was cancelled before we even started
|
|
$this->application_deployment_queue->refresh();
|
|
if ($this->application_deployment_queue->status === ApplicationDeploymentStatus::CANCELLED_BY_USER->value) {
|
|
$this->application_deployment_queue->addLogEntry('Deployment was cancelled before starting.');
|
|
$this->handleCancelledDeployment();
|
|
|
|
return;
|
|
}
|
|
|
|
try {
|
|
$this->validateDeploymentEnvironmentVariableKeys();
|
|
$this->ensureRegistryImageForMultipleServers();
|
|
$this->ensureRegistryImageIsNotShared();
|
|
$this->warnAboutVolumesOnMultipleServers();
|
|
$this->warnAboutMixedProxiesOnMultipleServers();
|
|
} catch (Exception $e) {
|
|
$this->fail($e);
|
|
throw $e;
|
|
}
|
|
|
|
// A conditional update, so a cancel that came after the check above is not overwritten.
|
|
ApplicationDeploymentQueue::query()
|
|
->whereKey($this->application_deployment_queue->getKey())
|
|
->where('status', '!=', ApplicationDeploymentStatus::CANCELLED_BY_USER->value)
|
|
->update([
|
|
'status' => ApplicationDeploymentStatus::IN_PROGRESS->value,
|
|
'horizon_job_worker' => gethostname(),
|
|
]);
|
|
$this->application_deployment_queue->refresh();
|
|
if ($this->application_deployment_queue->status === ApplicationDeploymentStatus::CANCELLED_BY_USER->value) {
|
|
$this->application_deployment_queue->addLogEntry('Deployment was cancelled before starting.');
|
|
$this->handleCancelledDeployment();
|
|
|
|
return;
|
|
}
|
|
if ($this->server->isFunctionalAfterRecheck() === false) {
|
|
$this->application_deployment_queue->addLogEntry('Server is not functional.');
|
|
$this->fail('Server is not functional.');
|
|
|
|
return;
|
|
}
|
|
try {
|
|
// Make sure the private key is stored in the filesystem
|
|
$this->server->privateKey->storeInFileSystem();
|
|
// Generate custom host<->ip mapping
|
|
$safeNetwork = escapeshellarg($this->destination->network);
|
|
$allContainers = instant_remote_process(["docker network inspect {$safeNetwork} -f '{{json .Containers}}' "], $this->server);
|
|
|
|
if (! is_null($allContainers)) {
|
|
$allContainers = format_docker_command_output_to_json($allContainers);
|
|
$this->addHosts = count($allContainers) > 0 ? $this->addHostFlags(collect($allContainers[0])) : '';
|
|
}
|
|
|
|
if ($this->application->dockerfile_target_build) {
|
|
$target = $this->application->dockerfile_target_build;
|
|
if (! preg_match(ValidationPatterns::DOCKER_TARGET_PATTERN, $target)) {
|
|
throw new \RuntimeException('Invalid dockerfile_target_build: contains forbidden characters.');
|
|
}
|
|
$this->buildTarget = " --target {$target} ";
|
|
}
|
|
|
|
// Check custom port
|
|
['repository' => $this->customRepository, 'port' => $this->customPort] = $this->application->customRepository();
|
|
|
|
$this->selectBuildServer();
|
|
$this->detectBuildKitCapabilities();
|
|
$this->decide_what_to_do();
|
|
} catch (Exception $e) {
|
|
if ($this->pull_request_id !== 0 && $this->application->isGithubAppSource()) {
|
|
ApplicationPullRequestUpdateJob::dispatch(application: $this->application, preview: $this->preview, deployment_uuid: $this->deployment_uuid, status: ProcessStatus::ERROR);
|
|
}
|
|
$this->fail($e);
|
|
throw $e;
|
|
} finally {
|
|
// Wrap cleanup operations in try-catch to prevent exceptions from interfering
|
|
// with Laravel's job failure handling and status updates
|
|
try {
|
|
$this->application_deployment_queue->update([
|
|
'finished_at' => Carbon::now()->toImmutable(),
|
|
]);
|
|
} catch (Exception $e) {
|
|
// Log but don't fail - finished_at is not critical
|
|
\Log::warning('Failed to update finished_at for deployment '.$this->deployment_uuid.': '.$e->getMessage());
|
|
}
|
|
|
|
try {
|
|
if ($this->use_build_server) {
|
|
$this->server = $this->build_server;
|
|
} else {
|
|
$this->write_deployment_configurations();
|
|
}
|
|
} catch (Exception $e) {
|
|
// Log but don't fail - configuration writing errors shouldn't prevent status updates
|
|
$this->application_deployment_queue->addLogEntry('Warning: Failed to write deployment configurations: '.$e->getMessage(), 'stderr');
|
|
}
|
|
|
|
try {
|
|
$this->application_deployment_queue->addLogEntry("Gracefully shutting down build container: {$this->deployment_uuid}");
|
|
$this->graceful_shutdown_container($this->deployment_uuid, skipRemove: true);
|
|
} catch (Exception $e) {
|
|
// Log but don't fail - container cleanup errors are expected when container is already gone
|
|
\Log::warning('Failed to shutdown container '.$this->deployment_uuid.': '.$e->getMessage());
|
|
}
|
|
|
|
try {
|
|
ServiceStatusChanged::dispatch(data_get($this->application, 'environment.project.team.id'));
|
|
} catch (Exception $e) {
|
|
// Log but don't fail - event dispatch errors shouldn't prevent status updates
|
|
\Log::warning('Failed to dispatch ServiceStatusChanged for deployment '.$this->deployment_uuid.': '.$e->getMessage());
|
|
}
|
|
}
|
|
}
|
|
|
|
private function selectBuildServer(): void
|
|
{
|
|
$this->build_server = $this->server;
|
|
|
|
// An additional server of a multi-server application does not build: it pulls the image the main server pushed.
|
|
if ($this->is_this_additional_server && str($this->application->docker_registry_image_name)->isNotEmpty()) {
|
|
// The main deployment already built the image (also for a forced rebuild or disabled build cache).
|
|
// A forced rebuild would skip the registry pull. Compose applications build on each server that can build.
|
|
if ($this->application->build_pack !== 'dockercompose' || ! $this->server->canBuildApplications()) {
|
|
$this->force_rebuild = false;
|
|
}
|
|
$this->application_deployment_queue->addLogEntry('Additional server: pulls the image from the registry, no build server needed.');
|
|
|
|
return;
|
|
}
|
|
|
|
// A deployments-only server never builds. Docker image and Compose applications are exempt:
|
|
// the first builds nothing and the second does not support build servers.
|
|
$mustBuildElsewhere = ! $this->server->canBuildApplications()
|
|
&& ! in_array($this->application->build_pack, ['dockerimage', 'dockercompose'], true);
|
|
|
|
if (! $mustBuildElsewhere && ! data_get($this->application, 'settings.is_build_server_enabled')) {
|
|
return;
|
|
}
|
|
|
|
if ($mustBuildElsewhere && ! $this->restart_only && str($this->application->docker_registry_image_name)->isEmpty()) {
|
|
throw new DeploymentException("The deployment server ({$this->server->name}) is set to deployments only, so this application is built on a build server. Set a Docker image name in the application's General settings so the deployment server can pull the built image.");
|
|
}
|
|
|
|
$team = $this->application->environment->project->team;
|
|
$buildServers = Server::buildServers($team->id)->whereKeyNot($this->server->id)->get();
|
|
|
|
if ($buildServers->isEmpty()) {
|
|
// A restart only rebuilds when the image is missing, so it may still run on the deployment server.
|
|
if ($mustBuildElsewhere && ! $this->restart_only) {
|
|
throw new DeploymentException("The deployment server ({$this->server->name}) is set to deployments only, and no usable build server was found. Add a build server or change the server role.");
|
|
}
|
|
// A restart that has to rebuild selects again in should_skip_build().
|
|
if (! $team->is_build_server_fallback_enabled && ! $this->restart_only) {
|
|
throw new DeploymentException('No available dedicated build server was found. Enable a usable build server for this team or allow fallback to the deployment server in the team settings.');
|
|
}
|
|
|
|
$this->application_deployment_queue->addLogEntry('No suitable build server found. Using the deployment server.');
|
|
|
|
return;
|
|
}
|
|
|
|
$this->build_server = $buildServers->random();
|
|
// Saved now: the cancel action looks for the helper container on this server.
|
|
$this->application_deployment_queue->update(['build_server_id' => $this->build_server->id]);
|
|
$this->application_deployment_queue->addLogEntry("Found a suitable build server ({$this->build_server->name}).");
|
|
$this->use_build_server = true;
|
|
}
|
|
|
|
private function detectBuildKitCapabilities(): void
|
|
{
|
|
$this->dockerBuildkitSupported = false;
|
|
$this->dockerBuildxAvailable = false;
|
|
$this->dockerSecretsSupported = false;
|
|
|
|
$serverToCheck = $this->use_build_server ? $this->build_server : $this->server;
|
|
$serverName = $this->use_build_server ? "build server ({$serverToCheck->name})" : "deployment server ({$serverToCheck->name})";
|
|
|
|
try {
|
|
$dockerVersion = instant_remote_process(
|
|
["docker version --format '{{.Server.Version}}'"],
|
|
$serverToCheck
|
|
);
|
|
$serverToCheck->rememberDockerVersion($dockerVersion);
|
|
|
|
$versionParts = explode('.', $dockerVersion);
|
|
$majorVersion = (int) $versionParts[0];
|
|
$minorVersion = (int) ($versionParts[1] ?? 0);
|
|
|
|
if ($majorVersion < 18 || ($majorVersion == 18 && $minorVersion < 9)) {
|
|
$this->dockerBuildkitSupported = false;
|
|
$this->dockerBuildxAvailable = false;
|
|
$this->application_deployment_queue->addLogEntry("Docker {$dockerVersion} on {$serverName} does not support BuildKit (requires 18.09+).");
|
|
|
|
return;
|
|
}
|
|
|
|
// Check buildx availability (always installed by Coolify on Docker 24.0+)
|
|
$buildxAvailable = instant_remote_process(
|
|
["docker buildx version >/dev/null 2>&1 && echo 'available' || echo 'not-available'"],
|
|
$serverToCheck
|
|
);
|
|
|
|
if (trim($buildxAvailable) === 'available') {
|
|
$this->dockerBuildkitSupported = true;
|
|
$this->dockerBuildxAvailable = true;
|
|
$this->application_deployment_queue->addLogEntry("Docker {$dockerVersion} with BuildKit and Buildx detected on {$serverName}.");
|
|
} else {
|
|
$this->dockerBuildxAvailable = false;
|
|
|
|
// Fallback: test DOCKER_BUILDKIT=1 support via --progress flag
|
|
$buildkitTest = instant_remote_process(
|
|
["DOCKER_BUILDKIT=1 docker build --help 2>&1 | grep -q '\\-\\-progress' && echo 'supported' || echo 'not-supported'"],
|
|
$serverToCheck
|
|
);
|
|
|
|
if (trim($buildkitTest) === 'supported') {
|
|
$this->dockerBuildkitSupported = true;
|
|
$this->application_deployment_queue->addLogEntry("Docker {$dockerVersion} with BuildKit support detected on {$serverName}.");
|
|
} else {
|
|
$this->dockerBuildkitSupported = false;
|
|
$this->application_deployment_queue->addLogEntry("Docker {$dockerVersion} on {$serverName} does not support BuildKit. Build output progress will be limited.");
|
|
}
|
|
}
|
|
|
|
if ($this->application->settings->use_build_secrets && $this->dockerBuildkitSupported) {
|
|
$secretsTest = instant_remote_process(
|
|
["docker build --help 2>&1 | grep -q 'secret' && echo 'supported' || echo 'not-supported'"],
|
|
$serverToCheck
|
|
);
|
|
|
|
if (trim($secretsTest) === 'supported') {
|
|
$this->dockerSecretsSupported = true;
|
|
$this->application_deployment_queue->addLogEntry('Build secrets are enabled and will be used for enhanced security.');
|
|
} else {
|
|
$this->application_deployment_queue->addLogEntry("Docker on {$serverName} does not support build secrets. Using traditional build arguments.");
|
|
}
|
|
}
|
|
} catch (Exception $e) {
|
|
$this->dockerBuildkitSupported = false;
|
|
$this->dockerBuildxAvailable = false;
|
|
$this->dockerSecretsSupported = false;
|
|
$this->application_deployment_queue->addLogEntry("Could not detect BuildKit capabilities on {$serverName}: {$e->getMessage()}");
|
|
}
|
|
}
|
|
|
|
private function decide_what_to_do()
|
|
{
|
|
if ($this->restart_only) {
|
|
$this->just_restart();
|
|
|
|
return;
|
|
} elseif ($this->application->build_pack === 'dockerimage') {
|
|
$this->deploy_dockerimage_buildpack();
|
|
} elseif ($this->pull_request_id !== 0) {
|
|
$this->deploy_pull_request();
|
|
} elseif ($this->application->dockerfile) {
|
|
$this->deploy_simple_dockerfile();
|
|
} elseif ($this->application->build_pack === 'dockercompose') {
|
|
$this->deploy_docker_compose_buildpack();
|
|
} elseif ($this->application->build_pack === 'dockerfile') {
|
|
$this->deploy_dockerfile_buildpack();
|
|
} elseif ($this->application->build_pack === 'static') {
|
|
$this->deploy_static_buildpack();
|
|
} elseif ($this->application->build_pack === 'nixpacks') {
|
|
$this->deploy_nixpacks_buildpack();
|
|
} elseif ($this->application->build_pack === 'railpack') {
|
|
$this->deploy_railpack_buildpack();
|
|
} else {
|
|
throw new DeploymentException("Unsupported build pack: {$this->application->build_pack}");
|
|
}
|
|
$this->post_deployment();
|
|
}
|
|
|
|
private function post_deployment()
|
|
{
|
|
// Mark deployment as complete FIRST, before any other operations
|
|
// This ensures the deployment status is FINISHED even if subsequent operations fail
|
|
$this->completeDeployment();
|
|
|
|
// Then handle side effects - these should not fail the deployment
|
|
try {
|
|
GetContainersStatus::dispatch($this->server);
|
|
} catch (Exception $e) {
|
|
\Log::warning('Failed to dispatch GetContainersStatus for deployment '.$this->deployment_uuid.': '.$e->getMessage());
|
|
}
|
|
|
|
if ($this->pull_request_id !== 0) {
|
|
if ($this->application->isGithubAppSource()) {
|
|
try {
|
|
ApplicationPullRequestUpdateJob::dispatch(application: $this->application, preview: $this->preview, deployment_uuid: $this->deployment_uuid, status: ProcessStatus::FINISHED);
|
|
} catch (Exception $e) {
|
|
\Log::warning('Failed to dispatch PR update for deployment '.$this->deployment_uuid.': '.$e->getMessage());
|
|
}
|
|
}
|
|
}
|
|
|
|
try {
|
|
$this->run_post_deployment_command();
|
|
} catch (Exception $e) {
|
|
\Log::warning('Post deployment command failed for '.$this->deployment_uuid.': '.$e->getMessage());
|
|
}
|
|
|
|
// Domains can change with a deployment, so the maintenance routes follow them.
|
|
if ($this->pull_request_id === 0 && $this->application->is_maintenance_enabled) {
|
|
try {
|
|
$this->application->syncMaintenancePage();
|
|
} catch (Exception $e) {
|
|
\Log::warning('Maintenance page update failed for '.$this->deployment_uuid.': '.$e->getMessage());
|
|
}
|
|
}
|
|
|
|
}
|
|
|
|
private function deploy_simple_dockerfile()
|
|
{
|
|
if ($this->use_build_server) {
|
|
$this->server = $this->build_server;
|
|
}
|
|
$dockerfile_base64 = base64_encode($this->application->dockerfile);
|
|
$this->application_deployment_queue->addLogEntry("Starting deployment of {$this->application->name} to {$this->server->name}.");
|
|
$this->prepare_builder_image();
|
|
$this->execute_remote_command(
|
|
[
|
|
executeInDocker($this->deployment_uuid, "echo '$dockerfile_base64' | base64 -d | tee {$this->workdir}{$this->dockerfile_location} > /dev/null"),
|
|
],
|
|
);
|
|
$this->generate_image_names();
|
|
$this->generate_compose_file();
|
|
if ($this->pull_image_for_additional_server()) {
|
|
$this->save_runtime_environment_variables();
|
|
$this->rolling_update();
|
|
|
|
return;
|
|
}
|
|
$this->validateDeploymentEnvironmentVariableKeys(buildsImage: true);
|
|
|
|
// Save build-time .env file BEFORE the build
|
|
$this->save_buildtime_environment_variables();
|
|
|
|
$this->generate_build_env_variables();
|
|
$this->add_build_env_variables_to_dockerfile();
|
|
$this->build_image();
|
|
|
|
// Save runtime environment variables AFTER the build
|
|
// This overwrites the build-time .env with ALL variables (build-time + runtime)
|
|
$this->save_runtime_environment_variables();
|
|
|
|
$this->push_to_docker_registry();
|
|
$this->rolling_update();
|
|
}
|
|
|
|
/**
|
|
* An inline Dockerfile image has no commit tag. The main server builds it and pushes it with the
|
|
* `latest` tag just before it queues the additional servers, so an additional server pulls that
|
|
* image instead of building its own. A missing image fails the deployment with a clear message.
|
|
*/
|
|
private function pull_image_for_additional_server(): bool
|
|
{
|
|
if (! $this->is_this_additional_server || str($this->application->docker_registry_image_name)->isEmpty()) {
|
|
return false;
|
|
}
|
|
|
|
$image = escapeshellarg($this->production_image_name);
|
|
$this->application_deployment_queue->addLogEntry("Pulling image ({$this->production_image_name}) that the main server pushed. Build step skipped.");
|
|
$this->execute_remote_command(
|
|
[
|
|
serverDockerConfigPrefix($this->server)."docker pull {$image}",
|
|
'hidden' => true,
|
|
],
|
|
[
|
|
"docker images -q {$image} 2>/dev/null",
|
|
'hidden' => true,
|
|
'save' => 'local_image_found',
|
|
],
|
|
);
|
|
|
|
if (str($this->saved_outputs->get('local_image_found'))->isEmpty()) {
|
|
$message = "Image ({$this->production_image_name}) not found in the registry; the main server must push it first.";
|
|
if (! $this->server->canBuildApplications()) {
|
|
$message .= " The server ({$this->server->name}) is set to deployments only and cannot build it.";
|
|
}
|
|
throw new DeploymentException($message);
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
private function deploy_dockerimage_buildpack()
|
|
{
|
|
$this->dockerImage = $this->application->docker_registry_image_name;
|
|
$this->dockerImageTag = $this->resolveDockerImageTag();
|
|
|
|
// Check if this is an image hash deployment
|
|
$isImageHash = str($this->dockerImageTag)->startsWith('sha256-');
|
|
$displayName = $isImageHash ? "{$this->dockerImage}@sha256:".str($this->dockerImageTag)->after('sha256-') : "{$this->dockerImage}:{$this->dockerImageTag}";
|
|
|
|
$this->application_deployment_queue->addLogEntry("Starting deployment of {$displayName} to {$this->server->name}.");
|
|
$this->generate_image_names();
|
|
$this->prepare_builder_image();
|
|
$this->generate_compose_file();
|
|
|
|
// Save runtime environment variables (including empty .env file if no variables defined)
|
|
$this->save_runtime_environment_variables();
|
|
|
|
$this->rolling_update();
|
|
}
|
|
|
|
private function resolveDockerImageTag(): string
|
|
{
|
|
if ($this->pull_request_id !== 0 && str($this->dockerImagePreviewTag)->isNotEmpty()) {
|
|
return $this->dockerImagePreviewTag;
|
|
}
|
|
|
|
if ($this->rollback && str($this->commit)->isNotEmpty()) {
|
|
return $this->commit;
|
|
}
|
|
|
|
if (str($this->application->docker_registry_image_tag)->isNotEmpty()) {
|
|
return $this->application->docker_registry_image_tag;
|
|
}
|
|
|
|
return 'latest';
|
|
}
|
|
|
|
private function deploy_docker_compose_buildpack()
|
|
{
|
|
if (data_get($this->application, 'docker_compose_location')) {
|
|
$this->docker_compose_location = $this->validatePathField($this->application->docker_compose_location, 'docker_compose_location');
|
|
}
|
|
if (data_get($this->application, 'docker_compose_custom_start_command')) {
|
|
$this->validateShellSafeCommand($this->application->docker_compose_custom_start_command, 'docker_compose_custom_start_command');
|
|
$this->docker_compose_custom_start_command = $this->application->docker_compose_custom_start_command;
|
|
if (! str($this->docker_compose_custom_start_command)->contains('--project-directory')) {
|
|
$projectDir = $this->preserveRepository ? $this->application->workdir() : $this->workdir;
|
|
$this->docker_compose_custom_start_command = str($this->docker_compose_custom_start_command)->replaceFirst('compose', 'compose --project-directory '.$projectDir)->value();
|
|
}
|
|
}
|
|
if (data_get($this->application, 'docker_compose_custom_build_command')) {
|
|
$this->validateShellSafeCommand($this->application->docker_compose_custom_build_command, 'docker_compose_custom_build_command');
|
|
$this->docker_compose_custom_build_command = $this->application->docker_compose_custom_build_command;
|
|
if (! str($this->docker_compose_custom_build_command)->contains('--project-directory')) {
|
|
$this->docker_compose_custom_build_command = str($this->docker_compose_custom_build_command)->replaceFirst('compose', 'compose --project-directory '.$this->workdir)->value();
|
|
}
|
|
}
|
|
if ($this->pull_request_id === 0) {
|
|
$this->application_deployment_queue->addLogEntry("Starting deployment of {$this->application->name} to {$this->server->name}.");
|
|
} else {
|
|
$this->application_deployment_queue->addLogEntry("Starting pull request (#{$this->pull_request_id}) deployment of {$this->customRepository}:{$this->application->git_branch} to {$this->server->name}.");
|
|
}
|
|
$this->prepare_builder_image();
|
|
$this->check_git_if_build_needed();
|
|
$this->clone_repository();
|
|
if ($this->preserveRepository) {
|
|
foreach ($this->application->fileStorages as $fileStorage) {
|
|
$path = $fileStorage->fs_path;
|
|
$saveName = 'file_stat_'.$fileStorage->id;
|
|
$realPathInGit = str($path)->replace($this->application->workdir(), $this->workdir)->value();
|
|
// check if the file is a directory or a file inside the repository
|
|
$this->execute_remote_command(
|
|
[executeInDocker($this->deployment_uuid, "stat -c '%F' ".escapeshellarg($realPathInGit)), 'hidden' => true, 'ignore_errors' => true, 'save' => $saveName]
|
|
);
|
|
if ($this->saved_outputs->has($saveName)) {
|
|
$fileStat = $this->trimmedSavedOutput($saveName);
|
|
if ($fileStat->value() === 'directory' && ! $fileStorage->is_directory) {
|
|
$fileStorage->is_directory = true;
|
|
$fileStorage->content = null;
|
|
$fileStorage->save();
|
|
$fileStorage->forPullRequest($this->previewFileStoragePullRequestId())->deleteStorageOnServer($this->mainServer);
|
|
$fileStorage->forPullRequest($this->previewFileStoragePullRequestId())->saveStorageOnServer($this->mainServer);
|
|
} elseif ($fileStat->value() === 'regular file' && $fileStorage->is_directory) {
|
|
$fileStorage->is_directory = false;
|
|
$fileStorage->is_based_on_git = true;
|
|
$fileStorage->save();
|
|
$fileStorage->forPullRequest($this->previewFileStoragePullRequestId())->deleteStorageOnServer($this->mainServer);
|
|
$fileStorage->forPullRequest($this->previewFileStoragePullRequestId())->saveStorageOnServer($this->mainServer);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
$this->generate_image_names();
|
|
$this->cleanup_git();
|
|
$this->validateDeploymentEnvironmentVariableKeys(buildsImage: true);
|
|
|
|
$this->generate_build_env_variables();
|
|
|
|
$this->loadComposeFileForDeployment();
|
|
$buildEnvironment = null;
|
|
if ($this->application->settings->is_raw_compose_deployment_enabled) {
|
|
$this->application->oldRawParser();
|
|
$yaml = $composeFile = $this->application->docker_compose_raw;
|
|
|
|
// For raw compose, we cannot automatically add secrets configuration
|
|
// User must define it manually in their docker-compose file
|
|
if ($this->dockerSecretsSupported && ! empty($this->build_secrets)) {
|
|
$this->application_deployment_queue->addLogEntry('Build secrets are configured. Ensure your docker-compose file includes build.secrets configuration for services that need them.');
|
|
}
|
|
} else {
|
|
$composeFile = $this->parseComposeFileForDeployment();
|
|
// Always add .env file to services
|
|
$services = collect(data_get($composeFile, 'services', []));
|
|
$services = $services->map(function ($service, $name) {
|
|
$service['env_file'] = ['.env'];
|
|
|
|
return $service;
|
|
});
|
|
$composeFile['services'] = $services->toArray();
|
|
|
|
// Add build secrets to compose file if enabled and BuildKit is supported
|
|
if ($this->dockerSecretsSupported && ! empty($this->build_secrets)) {
|
|
$composeFile = $this->add_build_secrets_to_compose($composeFile);
|
|
}
|
|
|
|
$buildEnvironment = $this->generate_buildtime_environment_variables();
|
|
$this->docker_compose_build_fingerprint = $this->docker_compose_build_fingerprint($buildEnvironment);
|
|
$composeFile = $this->add_build_fingerprint_to_compose(convertToArray($composeFile));
|
|
|
|
$yaml = Yaml::dump(convertToArray($composeFile), 10);
|
|
}
|
|
$this->generated_docker_compose = $yaml;
|
|
$this->execute_remote_command([
|
|
"docker exec {$this->deployment_uuid} rm -f {$this->workdir}{$this->docker_compose_location}",
|
|
'hidden' => true,
|
|
], [
|
|
"docker exec -i {$this->deployment_uuid} tee {$this->workdir}{$this->docker_compose_location} > /dev/null",
|
|
'input' => $yaml,
|
|
'hidden' => true,
|
|
]);
|
|
|
|
if ($this->can_reuse_docker_compose_images($composeFile)) {
|
|
$this->skip_build = true;
|
|
} else {
|
|
$this->build_docker_compose_images($composeFile, $buildEnvironment);
|
|
}
|
|
|
|
// Save runtime environment variables AFTER the build
|
|
// This overwrites the build-time .env with ALL variables (build-time + runtime)
|
|
$this->save_runtime_environment_variables();
|
|
|
|
$this->pull_docker_compose_images();
|
|
|
|
$this->startDeployPhase();
|
|
$this->stop_running_container(force: true);
|
|
$this->application_deployment_queue->addLogEntry('Starting new application.');
|
|
$networkId = $this->application->uuid;
|
|
if ($this->pull_request_id !== 0) {
|
|
$networkId = "{$this->application->uuid}-{$this->pull_request_id}";
|
|
}
|
|
if ($this->server->isSwarm()) {
|
|
// TODO
|
|
} else {
|
|
$this->execute_remote_command([
|
|
"docker network inspect '{$networkId}' >/dev/null 2>&1 || docker network create --attachable '{$networkId}' >/dev/null || true",
|
|
'hidden' => true,
|
|
'ignore_errors' => true,
|
|
], [
|
|
"docker network connect {$networkId} coolify-proxy >/dev/null 2>&1 || true",
|
|
'hidden' => true,
|
|
'ignore_errors' => true,
|
|
]);
|
|
}
|
|
|
|
$this->start_docker_compose_services();
|
|
|
|
$this->application_deployment_queue->addLogEntry('New container started.');
|
|
}
|
|
|
|
/**
|
|
* Builds the images of the Compose services that have a build section.
|
|
*/
|
|
private function build_docker_compose_images(mixed $composeFile, ?Collection $buildEnvironment): void
|
|
{
|
|
// Modify Dockerfiles for ARGs and build secrets
|
|
$this->modify_dockerfiles_for_compose($composeFile);
|
|
// Build new container to limit downtime.
|
|
$this->application_deployment_queue->addLogEntry('Pulling & building required images.');
|
|
|
|
// Save build-time .env file BEFORE the build
|
|
$buildEnvironment ??= $this->generate_buildtime_environment_variables();
|
|
$this->save_buildtime_environment_variables($buildEnvironment->merge($this->compose_build_interpolation_variables($composeFile, $buildEnvironment)));
|
|
|
|
if ($this->docker_compose_custom_build_command) {
|
|
// Auto-inject -f (compose file) and --env-file flags using helper function
|
|
$build_command = injectDockerComposeFlags(
|
|
$this->docker_compose_custom_build_command,
|
|
"{$this->workdir}{$this->docker_compose_location}",
|
|
self::BUILD_TIME_ENV_PATH
|
|
);
|
|
|
|
// Prepend DOCKER_BUILDKIT=1 if BuildKit is supported
|
|
if ($this->dockerBuildkitSupported) {
|
|
$build_command = "DOCKER_BUILDKIT=1 {$build_command}";
|
|
}
|
|
|
|
// Inject build arguments after build subcommand if not using build secrets
|
|
if (! $this->application->settings->use_build_secrets && $this->build_args instanceof Collection && $this->build_args->isNotEmpty()) {
|
|
$build_args_string = $this->build_args->implode(' ');
|
|
|
|
// Inject build args right after 'build' subcommand (not at the end)
|
|
$original_command = $build_command;
|
|
$build_command = injectDockerComposeBuildArgs($build_command, $build_args_string);
|
|
|
|
// Only log if build args were actually injected (command was modified)
|
|
if ($build_command !== $original_command) {
|
|
$this->application_deployment_queue->addLogEntry('Adding build arguments to custom Docker Compose build command.');
|
|
}
|
|
}
|
|
|
|
try {
|
|
$this->execute_remote_command(
|
|
[executeInDocker($this->deployment_uuid, "cd {$this->basedir} && {$build_command}"), 'hidden' => true],
|
|
);
|
|
} catch (\RuntimeException $e) {
|
|
if (str_contains($e->getMessage(), "matching `'") || str_contains($e->getMessage(), 'unexpected EOF')) {
|
|
throw new DeploymentException("Custom build command failed due to shell syntax error. Please check your command for special characters (like unmatched quotes): {$this->docker_compose_custom_build_command}");
|
|
}
|
|
|
|
throw $e;
|
|
}
|
|
} else {
|
|
$command = "{$this->coolify_variables} docker compose";
|
|
// Prepend DOCKER_BUILDKIT=1 if BuildKit is supported
|
|
if ($this->dockerBuildkitSupported) {
|
|
$command = "DOCKER_BUILDKIT=1 {$command}";
|
|
}
|
|
// Use build-time .env file from /artifacts (outside Docker context to prevent it from being in the image)
|
|
$command .= ' --env-file '.self::BUILD_TIME_ENV_PATH;
|
|
if ($this->force_rebuild) {
|
|
$command .= " --project-name {$this->application->uuid} --project-directory {$this->workdir} -f {$this->workdir}{$this->docker_compose_location} build --pull --no-cache";
|
|
} else {
|
|
$command .= " --project-name {$this->application->uuid} --project-directory {$this->workdir} -f {$this->workdir}{$this->docker_compose_location} build --pull";
|
|
}
|
|
|
|
if (! $this->application->settings->use_build_secrets && $this->build_args instanceof Collection && $this->build_args->isNotEmpty()) {
|
|
$build_args_string = $this->build_args->implode(' ');
|
|
$command .= " {$build_args_string}";
|
|
$this->application_deployment_queue->addLogEntry('Adding build arguments to Docker Compose build command.');
|
|
}
|
|
|
|
$this->execute_remote_command(
|
|
[executeInDocker($this->deployment_uuid, $command), 'hidden' => true],
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* `docker compose build` interpolates the whole Compose file, so a required runtime-only variable
|
|
* (`${KEY:?}` in `environment:`) fails the build when the build-time .env file does not define it.
|
|
* This gives the build the runtime .env lines of the variables that the file uses outside its build
|
|
* sections. A variable that a build section or a build secret can read is left out, so a
|
|
* runtime-only value never becomes a build argument or part of an image.
|
|
*
|
|
* @return Collection<int, string>
|
|
*/
|
|
private function compose_build_interpolation_variables(mixed $composeFile, Collection $buildEnvironment): Collection
|
|
{
|
|
$compose = is_string($composeFile) ? parseDockerComposeYaml($composeFile) : convertToArray($composeFile);
|
|
$references = fn (array $values): Collection => collect(Arr::flatten($values))
|
|
->flatMap(fn (mixed $value): Collection => extractDockerComposeEnvironmentVariableReferences($value));
|
|
|
|
$readByBuilds = collect(data_get($compose, 'secrets', []))->pluck('environment')->filter();
|
|
foreach (data_get($compose, 'services', []) as $serviceName => $service) {
|
|
$build = data_get($service, 'build');
|
|
if ($build === null) {
|
|
continue;
|
|
}
|
|
$args = data_get($build, 'args', []);
|
|
$argNames = is_array($args) && array_is_list($args)
|
|
? array_map(fn (mixed $arg): string => explode('=', (string) $arg, 2)[0], $args)
|
|
: array_keys((array) $args);
|
|
$readByBuilds = $readByBuilds->merge($argNames)->merge($references((array) $build));
|
|
unset($compose['services'][$serviceName]['build']);
|
|
}
|
|
|
|
$buildKeys = $buildEnvironment->map(fn (string $line): string => explode('=', $line, 2)[0]);
|
|
$keys = $references($compose)->unique()->diff($readByBuilds)->diff($buildKeys);
|
|
if ($keys->isEmpty()) {
|
|
return collect();
|
|
}
|
|
|
|
return $this->generate_runtime_environment_variables()
|
|
->filter(fn (string $line): bool => $keys->contains(explode('=', $line, 2)[0]))
|
|
->values();
|
|
}
|
|
|
|
/**
|
|
* Parses the Compose file for this deployment and writes the volume warnings of the parser
|
|
* (for example an external volume that the resource does not use yet) to the deployment log.
|
|
* A deployment of the application also queues one sync of the file storages with the server, which
|
|
* loads the content of the files that come from the repository. A preview uses its own copies of the
|
|
* files, so its deployment does not sync the storages of the application.
|
|
* The parser gives no services when it cannot parse the file. The deployment stops then, because
|
|
* an empty Compose file fails later with the unclear Docker error "no service selected".
|
|
*
|
|
* @return Collection<array-key, mixed>
|
|
*
|
|
* @throws DeploymentException When the parsed Compose file has no services
|
|
*/
|
|
private function parseComposeFileForDeployment(): Collection
|
|
{
|
|
$composeFile = collect($this->application->parse(pull_request_id: $this->pull_request_id, preview_id: data_get($this->preview, 'id'), commit: $this->commit));
|
|
if (blank(data_get($composeFile, 'services'))) {
|
|
throw new DeploymentException("The Docker Compose file has no services, or Coolify could not parse it. Check that {$this->application->docker_compose_location} is valid YAML and has a services section.");
|
|
}
|
|
if ($this->pull_request_id === 0) {
|
|
ServerFilesFromServerJob::dispatch($this->application);
|
|
}
|
|
foreach ($this->application->composeVolumeWarnings() as $warning) {
|
|
$this->application_deployment_queue->addLogEntry("Warning: {$warning}", 'stderr');
|
|
}
|
|
|
|
return $composeFile;
|
|
}
|
|
|
|
/**
|
|
* Pulls the registry images of the Compose services before the current deployment stops. Images that
|
|
* this deployment builds are not pulled: a pull fails for an image that is only local, and a pull of
|
|
* an image that is also in a registry replaces the new build with the old registry image.
|
|
*/
|
|
private function pull_docker_compose_images(): void
|
|
{
|
|
if ($this->docker_compose_custom_build_command && ! $this->use_build_server) {
|
|
$this->application_deployment_queue->addLogEntry('Skipping the image pull before stopping the current deployment, because the custom build command can build images outside of Docker Compose.');
|
|
|
|
return;
|
|
}
|
|
|
|
$services = $this->registry_image_compose_services();
|
|
if ($services->isEmpty()) {
|
|
return;
|
|
}
|
|
|
|
$this->application_deployment_queue->addLogEntry('Pulling image-based services before stopping the current deployment.');
|
|
|
|
$services = $services->map(fn (string $service) => escapeshellarg($service))->implode(' ');
|
|
if ($this->use_build_server) {
|
|
$this->write_deployment_configurations();
|
|
$this->server = $this->mainServer;
|
|
$workdir = $this->application->workdir();
|
|
$command = serverDockerConfigPrefix($this->server)."{$this->coolify_variables} docker compose --env-file {$workdir}/.env --project-name {$this->application->uuid} --project-directory {$workdir} -f {$workdir}{$this->docker_compose_location} pull --ignore-buildable {$services}";
|
|
} else {
|
|
$workdir = $this->workdir;
|
|
$command = executeInDocker($this->deployment_uuid, $this->withoutHelperBuildSecretEnvironment("{$this->coolify_variables} docker compose --env-file {$workdir}/.env --project-name {$this->application->uuid} --project-directory {$workdir} -f {$workdir}{$this->docker_compose_location} pull --ignore-buildable {$services}"));
|
|
}
|
|
|
|
$this->execute_remote_command([
|
|
$command,
|
|
'hidden' => true,
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* Returns the names of the Compose services that get their image from a registry. A service with a
|
|
* `build` section, or a service that uses the image of such a service, gets its image from the build.
|
|
*
|
|
* @return Collection<int, string>
|
|
*/
|
|
private function registry_image_compose_services(): Collection
|
|
{
|
|
$services = collect(data_get(Yaml::parse($this->generated_docker_compose), 'services', []));
|
|
$builtImages = $services->filter(fn ($service) => filled(data_get($service, 'build')))->pluck('image')->filter();
|
|
|
|
return $services
|
|
->filter(fn ($service) => filled(data_get($service, 'image'))
|
|
&& blank(data_get($service, 'build'))
|
|
&& ! $builtImages->contains(data_get($service, 'image')))
|
|
->keys()
|
|
->map(fn ($service) => (string) $service)
|
|
->values();
|
|
}
|
|
|
|
/**
|
|
* Starts the Compose containers. Content files are written first, because `docker compose up`
|
|
* creates a missing bind source as an empty directory.
|
|
*/
|
|
private function start_docker_compose_services(): void
|
|
{
|
|
$this->write_missing_content_files();
|
|
|
|
$server_workdir = $this->application->workdir();
|
|
if ($this->application->settings->is_raw_compose_deployment_enabled) {
|
|
if ($this->docker_compose_custom_start_command) {
|
|
// Auto-inject -f (compose file) and --env-file flags using helper function
|
|
$start_command = injectDockerComposeFlags(
|
|
$this->docker_compose_custom_start_command,
|
|
"{$server_workdir}{$this->docker_compose_location}",
|
|
"{$server_workdir}/.env"
|
|
);
|
|
|
|
$this->write_deployment_configurations();
|
|
|
|
try {
|
|
$this->execute_remote_command(
|
|
[executeInDocker($this->deployment_uuid, $this->withoutHelperBuildSecretEnvironment("cd {$this->workdir} && {$start_command}")), 'hidden' => false, 'type' => 'stdout', 'command_hidden' => true],
|
|
);
|
|
} catch (\RuntimeException $e) {
|
|
if (str_contains($e->getMessage(), "matching `'") || str_contains($e->getMessage(), 'unexpected EOF')) {
|
|
throw new DeploymentException("Custom start command failed due to shell syntax error. Please check your command for special characters (like unmatched quotes): {$this->docker_compose_custom_start_command}");
|
|
}
|
|
|
|
throw $e;
|
|
}
|
|
} else {
|
|
$this->write_deployment_configurations();
|
|
$this->docker_compose_location = '/docker-compose.yaml';
|
|
|
|
$command = "{$this->coolify_variables} docker compose";
|
|
// Always use .env file
|
|
$command .= " --env-file {$server_workdir}/.env";
|
|
$command .= " --project-directory {$server_workdir} -f {$server_workdir}{$this->docker_compose_location} up -d";
|
|
$this->execute_remote_command(
|
|
['command' => $command, 'hidden' => false, 'type' => 'stdout', 'command_hidden' => true],
|
|
);
|
|
}
|
|
} else {
|
|
if ($this->docker_compose_custom_start_command) {
|
|
// Auto-inject -f (compose file) and --env-file flags using helper function
|
|
// Use $this->workdir for non-preserve-repository mode
|
|
$workdir_path = $this->preserveRepository ? $server_workdir : $this->workdir;
|
|
$start_command = injectDockerComposeFlags(
|
|
$this->docker_compose_custom_start_command,
|
|
"{$workdir_path}{$this->docker_compose_location}",
|
|
"{$workdir_path}/.env"
|
|
);
|
|
|
|
$this->write_deployment_configurations();
|
|
if ($this->preserveRepository) {
|
|
$start_in_workdir = "cd {$server_workdir} && {$start_command}";
|
|
if ($this->server->isNonRoot()) {
|
|
// A non-root SSH user cannot cd into the resource directory on the Coolify host. As one
|
|
// `sh -c` script, the sudo parser runs the cd and the start command as root.
|
|
$start_in_workdir = 'sh -c '.escapeshellarg($start_in_workdir);
|
|
}
|
|
$this->execute_remote_command(
|
|
['command' => $start_in_workdir, 'hidden' => false, 'type' => 'stdout', 'command_hidden' => true],
|
|
);
|
|
} else {
|
|
$this->execute_remote_command(
|
|
[executeInDocker($this->deployment_uuid, $this->withoutHelperBuildSecretEnvironment("cd {$this->basedir} && {$start_command}")), 'hidden' => false, 'type' => 'stdout', 'command_hidden' => true],
|
|
);
|
|
}
|
|
} else {
|
|
$command = "{$this->coolify_variables} docker compose";
|
|
if ($this->preserveRepository) {
|
|
// Always use .env file
|
|
$command .= " --env-file {$server_workdir}/.env";
|
|
$command .= " --project-name {$this->application->uuid} --project-directory {$server_workdir} -f {$server_workdir}{$this->docker_compose_location} up -d";
|
|
$this->write_deployment_configurations();
|
|
|
|
$this->execute_remote_command(
|
|
['command' => $command, 'hidden' => false, 'type' => 'stdout', 'command_hidden' => true],
|
|
);
|
|
} else {
|
|
// Always use .env file
|
|
$command .= " --env-file {$this->workdir}/.env";
|
|
$command .= " --project-name {$this->application->uuid} --project-directory {$this->workdir} -f {$this->workdir}{$this->docker_compose_location} up -d";
|
|
$this->execute_remote_command(
|
|
[executeInDocker($this->deployment_uuid, $this->withoutHelperBuildSecretEnvironment($command)), 'hidden' => false, 'type' => 'stdout', 'command_hidden' => true],
|
|
);
|
|
$this->write_deployment_configurations();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* The pull request whose file storage paths this deployment writes. Only the current Compose parser
|
|
* mounts the `-pr-<id>` copies in a preview; other build packs and the legacy parser mount the
|
|
* paths of the application.
|
|
*/
|
|
private function previewFileStoragePullRequestId(): int
|
|
{
|
|
return $this->application->build_pack === 'dockercompose' && (int) $this->application->compose_parsing_version >= 3
|
|
? $this->pull_request_id
|
|
: 0;
|
|
}
|
|
|
|
/**
|
|
* The queued ServerStorageSaveJob writes new content files, but it can run after the containers
|
|
* start. Preserve-repository deployments write all file storages in write_deployment_configurations().
|
|
* Each deployment writes the missing files on its own server before the containers start, so
|
|
* additional servers also get them.
|
|
*/
|
|
private function write_missing_content_files(): void
|
|
{
|
|
if ($this->preserveRepository) {
|
|
return;
|
|
}
|
|
|
|
EnsureContentFilesOnServer::run(
|
|
$this->application->fileStorages()->get()->map->forPullRequest($this->previewFileStoragePullRequestId()),
|
|
$this->mainServer,
|
|
function (string $message, string $type): void {
|
|
$this->application_deployment_queue->addLogEntry($message, $type);
|
|
},
|
|
);
|
|
}
|
|
|
|
private function deploy_dockerfile_buildpack()
|
|
{
|
|
$this->application_deployment_queue->addLogEntry("Starting deployment of {$this->customRepository}:{$this->application->git_branch} to {$this->server->name}.");
|
|
if ($this->use_build_server) {
|
|
$this->server = $this->build_server;
|
|
}
|
|
if (data_get($this->application, 'dockerfile_location')) {
|
|
$this->dockerfile_location = $this->validatePathField($this->application->dockerfile_location, 'dockerfile_location');
|
|
}
|
|
$this->prepare_builder_image();
|
|
$this->check_git_if_build_needed();
|
|
$this->generate_image_names();
|
|
$this->clone_repository();
|
|
if (! $this->force_rebuild) {
|
|
$this->check_image_locally_or_remotely();
|
|
if ($this->should_skip_build()) {
|
|
return;
|
|
}
|
|
}
|
|
$this->validateDeploymentEnvironmentVariableKeys(buildsImage: true);
|
|
$this->cleanup_git();
|
|
$this->generate_compose_file();
|
|
|
|
// Save build-time .env file BEFORE the build
|
|
$this->save_buildtime_environment_variables();
|
|
|
|
$this->generate_build_env_variables();
|
|
$this->add_build_env_variables_to_dockerfile();
|
|
$this->build_image();
|
|
|
|
// Save runtime environment variables AFTER the build
|
|
// This overwrites the build-time .env with ALL variables (build-time + runtime)
|
|
$this->save_runtime_environment_variables();
|
|
|
|
$this->push_to_docker_registry();
|
|
$this->rolling_update();
|
|
}
|
|
|
|
private function deploy_nixpacks_buildpack()
|
|
{
|
|
if ($this->use_build_server) {
|
|
$this->server = $this->build_server;
|
|
}
|
|
$this->application_deployment_queue->addLogEntry("Starting deployment of {$this->customRepository}:{$this->application->git_branch} to {$this->server->name}.");
|
|
$this->prepare_builder_image();
|
|
$this->check_git_if_build_needed();
|
|
$this->generate_image_names();
|
|
if (! $this->force_rebuild) {
|
|
$this->check_image_locally_or_remotely();
|
|
if ($this->should_skip_build()) {
|
|
return;
|
|
}
|
|
}
|
|
$this->validateDeploymentEnvironmentVariableKeys(buildsImage: true);
|
|
$this->clone_repository();
|
|
$this->cleanup_git();
|
|
$this->generate_nixpacks_confs();
|
|
$this->generate_compose_file();
|
|
|
|
// Save build-time .env file BEFORE the build for Nixpacks
|
|
$this->save_buildtime_environment_variables();
|
|
|
|
$this->generate_build_env_variables();
|
|
$this->build_image();
|
|
|
|
// For Nixpacks, save runtime environment variables AFTER the build
|
|
// This overwrites the build-time .env with ALL variables (build-time + runtime)
|
|
$this->save_runtime_environment_variables();
|
|
$this->push_to_docker_registry();
|
|
$this->rolling_update();
|
|
}
|
|
|
|
private function deploy_railpack_buildpack(): void
|
|
{
|
|
if ($this->use_build_server) {
|
|
$this->server = $this->build_server;
|
|
}
|
|
$this->application_deployment_queue->addLogEntry("Starting deployment of {$this->customRepository}:{$this->application->git_branch} to {$this->server->name}.");
|
|
$this->prepare_builder_image();
|
|
$this->check_git_if_build_needed();
|
|
$this->generate_image_names();
|
|
if (! $this->force_rebuild) {
|
|
$this->check_image_locally_or_remotely();
|
|
if ($this->should_skip_build()) {
|
|
return;
|
|
}
|
|
}
|
|
$this->validateDeploymentEnvironmentVariableKeys(buildsImage: true);
|
|
$this->clone_repository();
|
|
$this->cleanup_git();
|
|
$this->generate_compose_file();
|
|
|
|
// Save build-time .env file BEFORE the build
|
|
$this->save_buildtime_environment_variables();
|
|
|
|
$this->generate_build_env_variables();
|
|
$this->build_railpack_image();
|
|
|
|
// Save runtime environment variables AFTER the build
|
|
$this->save_runtime_environment_variables();
|
|
$this->push_to_docker_registry();
|
|
$this->rolling_update();
|
|
}
|
|
|
|
private function deploy_static_buildpack()
|
|
{
|
|
if ($this->use_build_server) {
|
|
$this->server = $this->build_server;
|
|
}
|
|
$this->application_deployment_queue->addLogEntry("Starting deployment of {$this->customRepository}:{$this->application->git_branch} to {$this->server->name}.");
|
|
$this->prepare_builder_image();
|
|
$this->check_git_if_build_needed();
|
|
$this->generate_image_names();
|
|
if (! $this->force_rebuild) {
|
|
$this->check_image_locally_or_remotely();
|
|
if ($this->should_skip_build()) {
|
|
return;
|
|
}
|
|
}
|
|
$this->validateDeploymentEnvironmentVariableKeys(buildsImage: true);
|
|
$this->clone_repository();
|
|
$this->cleanup_git();
|
|
$this->generate_compose_file();
|
|
|
|
// Save build-time .env file BEFORE the build
|
|
$this->save_buildtime_environment_variables();
|
|
|
|
$this->build_static_image();
|
|
|
|
// Save runtime environment variables AFTER the build
|
|
// This overwrites the build-time .env with ALL variables (build-time + runtime)
|
|
$this->save_runtime_environment_variables();
|
|
|
|
$this->push_to_docker_registry();
|
|
$this->rolling_update();
|
|
}
|
|
|
|
private function write_deployment_configurations()
|
|
{
|
|
if ($this->preserveRepository) {
|
|
if ($this->use_build_server) {
|
|
$this->server = $this->mainServer;
|
|
}
|
|
if (str($this->configuration_dir)->isNotEmpty()) {
|
|
$this->execute_remote_command(
|
|
[
|
|
"mkdir -p $this->configuration_dir",
|
|
],
|
|
[
|
|
"docker cp {$this->deployment_uuid}:{$this->workdir}/. {$this->configuration_dir}",
|
|
],
|
|
);
|
|
}
|
|
foreach ($this->application->fileStorages as $fileStorage) {
|
|
if (! $fileStorage->is_host_file && ! $fileStorage->is_based_on_git && ! $fileStorage->is_directory) {
|
|
$fileStorage->forPullRequest($this->previewFileStoragePullRequestId())->saveStorageOnServer($this->mainServer);
|
|
}
|
|
}
|
|
if ($this->use_build_server) {
|
|
$this->server = $this->build_server;
|
|
}
|
|
}
|
|
if (isset($this->generated_docker_compose)) {
|
|
if ($this->use_build_server) {
|
|
$this->server = $this->mainServer;
|
|
}
|
|
$readme = generate_readme_file($this->application->name, $this->application_deployment_queue->updated_at);
|
|
|
|
$mainDir = $this->configuration_dir;
|
|
if ($this->application->settings->is_raw_compose_deployment_enabled) {
|
|
$mainDir = $this->application->workdir();
|
|
}
|
|
if ($this->pull_request_id === 0) {
|
|
$composeFileName = "$mainDir/docker-compose.yaml";
|
|
} else {
|
|
$composeFileName = "$mainDir/".addPreviewDeploymentSuffix('docker-compose', $this->pull_request_id).'.yaml';
|
|
$this->docker_compose_location = '/'.addPreviewDeploymentSuffix('docker-compose', $this->pull_request_id).'.yaml';
|
|
}
|
|
// `docker cp` above can copy symlinks from the repository to these names. Remove them first, so that
|
|
// Coolify writes regular files.
|
|
$this->execute_remote_command(
|
|
[
|
|
"mkdir -p $mainDir",
|
|
],
|
|
[
|
|
"rm -f $composeFileName $mainDir/README.md",
|
|
'hidden' => true,
|
|
],
|
|
[
|
|
"tee $composeFileName > /dev/null",
|
|
'input' => $this->generated_docker_compose,
|
|
],
|
|
[
|
|
"echo '{$readme}' | tee $mainDir/README.md > /dev/null",
|
|
]
|
|
);
|
|
if ($this->use_build_server) {
|
|
$this->server = $this->build_server;
|
|
}
|
|
}
|
|
}
|
|
|
|
private function push_to_docker_registry()
|
|
{
|
|
if (str($this->application->docker_registry_image_name)->isEmpty()) {
|
|
return;
|
|
}
|
|
if ($this->restart_only) {
|
|
return;
|
|
}
|
|
if ($this->application->build_pack === 'dockerimage') {
|
|
return;
|
|
}
|
|
if ($this->is_this_additional_server) {
|
|
return;
|
|
}
|
|
try {
|
|
instant_remote_process(["docker images --format '{{json .}}' {$this->production_image_name}"], $this->server);
|
|
$this->application_deployment_queue->addLogEntry('----------------------------------------');
|
|
$this->application_deployment_queue->addLogEntry("Pushing image to docker registry ({$this->production_image_name}).");
|
|
$this->execute_remote_command(
|
|
[
|
|
executeInDocker($this->deployment_uuid, 'docker push '.escapeshellarg($this->production_image_name)),
|
|
'hidden' => true,
|
|
],
|
|
);
|
|
if ($this->shouldPushDockerRegistryImageTag()) {
|
|
// Tag image with docker_registry_image_tag
|
|
$this->application_deployment_queue->addLogEntry("Tagging and pushing image with {$this->application->docker_registry_image_tag} tag.");
|
|
$this->execute_remote_command(
|
|
[
|
|
executeInDocker($this->deployment_uuid, 'docker tag '.escapeshellarg($this->production_image_name).' '.escapeshellarg("{$this->application->docker_registry_image_name}:{$this->application->docker_registry_image_tag}")),
|
|
'ignore_errors' => true,
|
|
'hidden' => true,
|
|
],
|
|
[
|
|
executeInDocker($this->deployment_uuid, 'docker push '.escapeshellarg("{$this->application->docker_registry_image_name}:{$this->application->docker_registry_image_tag}")),
|
|
'ignore_errors' => true,
|
|
'hidden' => true,
|
|
],
|
|
);
|
|
}
|
|
} catch (Exception $e) {
|
|
$this->application_deployment_queue->addLogEntry('Failed to push image to docker registry. Please check debug logs for more information.');
|
|
throw new DeploymentException(get_class($e).': '.$e->getMessage(), $e->getCode(), $e);
|
|
}
|
|
}
|
|
|
|
private function shouldPushDockerRegistryImageTag(): bool
|
|
{
|
|
if (blank($this->application->docker_registry_image_tag)) {
|
|
return false;
|
|
}
|
|
|
|
return $this->pull_request_id === 0;
|
|
}
|
|
|
|
private function validateDockerRegistryImageConfiguration(): void
|
|
{
|
|
if (! ValidationPatterns::isValidDockerImageName($this->application->docker_registry_image_name)) {
|
|
throw new DeploymentException('Docker registry image name contains invalid characters.');
|
|
}
|
|
|
|
if (! ValidationPatterns::isValidDockerImageTag($this->application->docker_registry_image_tag)) {
|
|
throw new DeploymentException('Docker registry image tag contains invalid characters.');
|
|
}
|
|
|
|
if (! ValidationPatterns::isValidDockerImageTag($this->dockerImagePreviewTag)) {
|
|
throw new DeploymentException('Docker registry preview image tag contains invalid characters.');
|
|
}
|
|
}
|
|
|
|
private function generate_image_names()
|
|
{
|
|
if ($this->application->dockerfile) {
|
|
if ($this->application->docker_registry_image_name) {
|
|
$this->build_image_name = "{$this->application->docker_registry_image_name}:build";
|
|
$this->production_image_name = "{$this->application->docker_registry_image_name}:latest";
|
|
} else {
|
|
$this->build_image_name = "{$this->application->uuid}:build";
|
|
$this->production_image_name = "{$this->application->uuid}:latest";
|
|
}
|
|
} elseif ($this->application->build_pack === 'dockerimage') {
|
|
// Check if this is an image hash deployment
|
|
if (str($this->dockerImageTag)->startsWith('sha256-')) {
|
|
$hash = str($this->dockerImageTag)->after('sha256-');
|
|
$this->production_image_name = "{$this->dockerImage}@sha256:{$hash}";
|
|
} else {
|
|
$this->production_image_name = "{$this->dockerImage}:{$this->dockerImageTag}";
|
|
}
|
|
} elseif ($this->pull_request_id !== 0) {
|
|
$previewImageTag = $this->previewImageTag();
|
|
$previewBuildImageTag = $this->previewImageTag(build: true);
|
|
|
|
if ($this->application->docker_registry_image_name) {
|
|
$this->build_image_name = "{$this->application->docker_registry_image_name}:{$previewBuildImageTag}";
|
|
$this->production_image_name = "{$this->application->docker_registry_image_name}:{$previewImageTag}";
|
|
} else {
|
|
$this->build_image_name = "{$this->application->uuid}:{$previewBuildImageTag}";
|
|
$this->production_image_name = "{$this->application->uuid}:{$previewImageTag}";
|
|
}
|
|
} else {
|
|
$this->dockerImageTag = str($this->commit)->substr(0, 128);
|
|
// if ($this->application->docker_registry_image_tag) {
|
|
// $this->dockerImageTag = $this->application->docker_registry_image_tag;
|
|
// }
|
|
if ($this->application->docker_registry_image_name) {
|
|
$this->build_image_name = "{$this->application->docker_registry_image_name}:{$this->dockerImageTag}-build";
|
|
$this->production_image_name = "{$this->application->docker_registry_image_name}:{$this->dockerImageTag}";
|
|
} else {
|
|
$this->build_image_name = "{$this->application->uuid}:{$this->dockerImageTag}-build";
|
|
$this->production_image_name = "{$this->application->uuid}:{$this->dockerImageTag}";
|
|
}
|
|
}
|
|
}
|
|
|
|
private function previewImageTag(bool $build = false): string
|
|
{
|
|
$prefix = "pr-{$this->pull_request_id}-";
|
|
$suffix = $build ? '-build' : '';
|
|
$maxCommitLength = max(1, 128 - strlen($prefix) - strlen($suffix));
|
|
$commitSource = ($this->commit === 'HEAD' || blank($this->commit))
|
|
? $this->deployment_uuid
|
|
: $this->commit;
|
|
|
|
$commit = Str::of($commitSource)
|
|
->replaceMatches('/[^A-Za-z0-9_.-]/', '-')
|
|
->substr(0, $maxCommitLength)
|
|
->toString();
|
|
|
|
if ($commit === '') {
|
|
$commit = 'HEAD';
|
|
}
|
|
|
|
return "{$prefix}{$commit}{$suffix}";
|
|
}
|
|
|
|
private function just_restart()
|
|
{
|
|
$this->application_deployment_queue->addLogEntry("Restarting {$this->customRepository}:{$this->application->git_branch} on {$this->server->name}.");
|
|
|
|
// Restart doesn't need the build server — disable it so the helper container
|
|
// is created on the deployment server with the correct network/flags.
|
|
$originalUseBuildServer = $this->use_build_server;
|
|
$this->use_build_server = false;
|
|
|
|
$this->prepare_builder_image();
|
|
$this->check_git_if_build_needed();
|
|
$this->generate_image_names();
|
|
$this->check_image_locally_or_remotely();
|
|
|
|
// Restore before should_skip_build() — it may re-enter decide_what_to_do()
|
|
// for a full rebuild which needs the build server.
|
|
$this->use_build_server = $originalUseBuildServer;
|
|
|
|
$this->should_skip_build();
|
|
$this->completeDeployment();
|
|
}
|
|
|
|
private function should_skip_build()
|
|
{
|
|
if (str($this->saved_outputs->get('local_image_found'))->isNotEmpty()) {
|
|
if ($this->is_this_additional_server) {
|
|
$this->skip_build = true;
|
|
$this->application_deployment_queue->addLogEntry("Image found ({$this->production_image_name}) with the same Git Commit SHA. Build step skipped.");
|
|
$this->generate_compose_file();
|
|
|
|
// Save runtime environment variables even when skipping build
|
|
$this->save_runtime_environment_variables();
|
|
|
|
$this->push_to_docker_registry();
|
|
$this->rolling_update();
|
|
|
|
return true;
|
|
}
|
|
if ($this->has_remote_buildtime_secret_references()) {
|
|
$this->application_deployment_queue->addLogEntry('Remote build-time secrets are configured. Running the build to check for updated values.');
|
|
|
|
return false;
|
|
}
|
|
$configurationDiff = $this->application->pendingDeploymentConfigurationDiff();
|
|
if (! $configurationDiff->requiresBuild()) {
|
|
$this->application_deployment_queue->addLogEntry("No build configuration changed & image found ({$this->production_image_name}) with the same Git Commit SHA. Build step skipped.");
|
|
$this->skip_build = true;
|
|
$this->generate_compose_file();
|
|
|
|
// Save runtime environment variables even when skipping build
|
|
$this->save_runtime_environment_variables();
|
|
|
|
$this->push_to_docker_registry();
|
|
$this->rolling_update();
|
|
|
|
return true;
|
|
} elseif ($configurationDiff->isLegacyFallback()) {
|
|
$this->application_deployment_queue->addLogEntry('The last deployment stored no configuration snapshot. Rebuilding image.');
|
|
} else {
|
|
$this->application_deployment_queue->addLogEntry('Build configuration changed. Rebuilding image.');
|
|
}
|
|
} else {
|
|
if ($this->is_this_additional_server && $this->application->build_pack !== 'dockercompose') {
|
|
$message = "Image ({$this->production_image_name}) not found in the registry; the main server must push it first.";
|
|
$message .= $this->server->canBuildApplications()
|
|
? ' Additional servers do not build, so they run the same image as the main server.'
|
|
: " The server ({$this->server->name}) is set to deployments only and cannot build it.";
|
|
throw new DeploymentException($message);
|
|
}
|
|
$this->application_deployment_queue->addLogEntry("Image not found ({$this->production_image_name}). Building new image.");
|
|
}
|
|
if ($this->restart_only) {
|
|
$this->restart_only = false;
|
|
if (! $this->use_build_server) {
|
|
// The restart skipped build server selection.
|
|
$this->selectBuildServer();
|
|
if ($this->use_build_server) {
|
|
$this->detectBuildKitCapabilities();
|
|
}
|
|
}
|
|
$this->decide_what_to_do();
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Hash of every input that changes a Compose build: the build settings, the resolved build-time
|
|
* variables (with domains, shared variables, and secrets), and the variables of the build command.
|
|
* The hash goes on the built images as a label, so a later deployment can see what an image was built from.
|
|
*/
|
|
private function docker_compose_build_fingerprint(Collection $buildEnvironment): string
|
|
{
|
|
$buildSettings = collect((new ApplicationConfigurationSnapshot($this->application))->toArray()['sections'])
|
|
->flatMap(fn (array $section) => $section['items'])
|
|
->where('impact', 'build')
|
|
->mapWithKeys(fn (array $item) => [$item['key'] => $item['compare_value']]);
|
|
|
|
return hash('sha256', json_encode([$buildSettings, $buildEnvironment->values(), $this->coolify_variables]));
|
|
}
|
|
|
|
private function add_build_fingerprint_to_compose(array $composeFile): array
|
|
{
|
|
foreach ($composeFile['services'] ?? [] as $serviceName => $service) {
|
|
if (! isset($service['build'])) {
|
|
continue;
|
|
}
|
|
if (is_string($service['build'])) {
|
|
$service['build'] = ['context' => $service['build']];
|
|
}
|
|
$labels = $service['build']['labels'] ?? [];
|
|
if (array_is_list($labels)) {
|
|
$labels[] = self::BUILD_FINGERPRINT_LABEL.'='.$this->docker_compose_build_fingerprint;
|
|
} else {
|
|
$labels[self::BUILD_FINGERPRINT_LABEL] = $this->docker_compose_build_fingerprint;
|
|
}
|
|
$service['build']['labels'] = $labels;
|
|
$composeFile['services'][$serviceName] = $service;
|
|
}
|
|
|
|
return $composeFile;
|
|
}
|
|
|
|
/**
|
|
* Whether every Compose service with a build section has an image on the server for this commit
|
|
* that was built from the same inputs, so the deployment can start it without a build.
|
|
* Only images tagged by the parser with the commit qualify: an image name set in the Compose
|
|
* file can hold an older build.
|
|
*/
|
|
private function can_reuse_docker_compose_images(mixed $composeFile): bool
|
|
{
|
|
if ($this->force_rebuild
|
|
|| $this->use_build_server
|
|
|| $this->pull_request_id !== 0
|
|
|| $this->application->settings->is_raw_compose_deployment_enabled
|
|
|| $this->docker_compose_custom_build_command
|
|
|| blank($this->docker_compose_build_fingerprint)
|
|
|| blank($this->commit)
|
|
|| $this->commit === 'HEAD') {
|
|
return false;
|
|
}
|
|
|
|
$images = collect(data_get($composeFile, 'services', []))
|
|
->filter(fn ($service) => data_get($service, 'build') !== null)
|
|
->map(fn ($service, $name) => [data_get($service, 'image'), "{$this->application->uuid}_{$name}:{$this->commit}"]);
|
|
if ($images->isEmpty() || $images->contains(fn (array $image) => $image[0] !== $image[1])) {
|
|
return false;
|
|
}
|
|
|
|
$imageNames = $images->map(fn (array $image) => escapeshellarg($image[1]))->implode(' ');
|
|
$format = escapeshellarg('{{ index .Config.Labels "'.self::BUILD_FINGERPRINT_LABEL.'" }}');
|
|
$this->execute_remote_command([
|
|
"docker image inspect --format {$format} {$imageNames} 2>/dev/null",
|
|
'hidden' => true,
|
|
'ignore_errors' => true,
|
|
'save' => 'compose_image_fingerprints',
|
|
]);
|
|
$fingerprints = $this->trimmedSavedOutput('compose_image_fingerprints')->explode("\n")->map(fn (string $line) => trim($line));
|
|
if ($fingerprints->count() !== $images->count() || $fingerprints->contains(fn (string $fingerprint) => $fingerprint !== $this->docker_compose_build_fingerprint)) {
|
|
return false;
|
|
}
|
|
|
|
$this->application_deployment_queue->addLogEntry('Images found with the same Git Commit SHA and build inputs. Build step skipped.');
|
|
|
|
return true;
|
|
}
|
|
|
|
private function has_remote_buildtime_secret_references(): bool
|
|
{
|
|
$environmentVariables = $this->pull_request_id === 0
|
|
? $this->application->environment_variables()
|
|
: $this->application->environment_variables_preview();
|
|
|
|
return $environmentVariables
|
|
->where('is_buildtime', true)
|
|
->get(['value', 'is_literal'])
|
|
->contains(fn (EnvironmentVariable $environmentVariable) => $this->uses_remote_secret_references($environmentVariable));
|
|
}
|
|
|
|
private function check_image_locally_or_remotely()
|
|
{
|
|
$this->execute_remote_command([
|
|
"docker images -q {$this->production_image_name} 2>/dev/null",
|
|
'hidden' => true,
|
|
'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([
|
|
serverDockerConfigPrefix($this->server).'docker pull '.escapeshellarg($this->production_image_name).' 2>/dev/null',
|
|
'ignore_errors' => true,
|
|
'hidden' => true,
|
|
]);
|
|
$this->execute_remote_command([
|
|
"docker images -q {$this->production_image_name} 2>/dev/null",
|
|
'hidden' => true,
|
|
'save' => 'local_image_found',
|
|
]);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Fetch the secrets from the application's secret manager source. Values
|
|
* live only in memory during the deployment and in the generated .env on
|
|
* the server — they are never persisted in the Coolify database. Fetched
|
|
* lazily (only when a variable references a secret), once per deployment.
|
|
* A fetch failure fails the deployment.
|
|
*
|
|
* @return array<string, string>
|
|
*/
|
|
private function remote_secrets(): array
|
|
{
|
|
if ($this->remote_secrets_cache !== null) {
|
|
return $this->remote_secrets_cache;
|
|
}
|
|
|
|
$link = $this->application->secretManagerLink()->with('integrationToken')->first();
|
|
|
|
if (! $link) {
|
|
throw new DeploymentException('Environment variables reference remote secrets ({{vault.KEY}}), but no secret manager source is configured for this application.');
|
|
}
|
|
|
|
$provider = $link->integrationToken->providerName();
|
|
$tokenName = $link->integrationToken->name;
|
|
|
|
try {
|
|
$secrets = $link->fetchSecrets();
|
|
} catch (Throwable $e) {
|
|
$this->application_deployment_queue->addLogEntry("Failed to fetch secrets from {$provider} ({$tokenName}, {$link->sourceSummary()}): {$e->getMessage()}", 'stderr');
|
|
|
|
throw new DeploymentException("Could not fetch secrets from {$provider}. The deployment was stopped so the application does not start with missing secrets.");
|
|
}
|
|
|
|
$this->application_deployment_queue->redactRemoteSecrets($secrets);
|
|
$this->application_deployment_queue->addLogEntry('Fetched '.count($secrets)." secrets from {$provider} ({$tokenName}, {$link->sourceSummary()}).");
|
|
|
|
return $this->remote_secrets_cache = $secrets;
|
|
}
|
|
|
|
/**
|
|
* Reads the Compose file from the repository again (also for pull request previews). If the file is not
|
|
* safe to use, the deployment stops here with a visible log line, before a command uses the file.
|
|
*/
|
|
private function loadComposeFileForDeployment(): void
|
|
{
|
|
try {
|
|
$this->application->loadComposeFile(isInit: false);
|
|
} catch (DeploymentException $e) {
|
|
// Deployment logs escape HTML themselves, so undo the escaping that error toasts need.
|
|
$message = 'Deployment stopped: '.lcfirst(html_entity_decode($e->getMessage(), ENT_QUOTES | ENT_HTML5));
|
|
$this->application_deployment_queue->addLogEntry($message, 'stderr');
|
|
|
|
throw DeploymentException::alreadyLogged($message, previous: $e);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Replace {{vault.KEY}} references with values from the configured secret
|
|
* manager source. Missing keys fail the deployment with a
|
|
* list — changing the source never re-checks references, so this is the
|
|
* moment problems surface. Values without references are returned as-is
|
|
* and never fetch secrets.
|
|
*/
|
|
private function substitute_remote_secrets(string $value, string $envKey): string
|
|
{
|
|
if (! RemoteSecretReferences::containsReference($value)) {
|
|
return $value;
|
|
}
|
|
|
|
$secrets = $this->remote_secrets();
|
|
$missing = RemoteSecretReferences::missingKeys($value, $secrets);
|
|
|
|
if ($missing !== []) {
|
|
$message = 'Missing secret keys: '.implode(', ', $missing)." (referenced by {$envKey}). Check the secret manager source of this application.";
|
|
$this->application_deployment_queue->addLogEntry($message, 'stderr');
|
|
|
|
throw DeploymentException::alreadyLogged($message);
|
|
}
|
|
|
|
return RemoteSecretReferences::substitute($value, $secrets);
|
|
}
|
|
|
|
/**
|
|
* Resolve shared variables, then secret references, in a raw variable value.
|
|
*/
|
|
private function resolve_environment_variable_raw(EnvironmentVariable $env): string
|
|
{
|
|
$value = $env->get_real_environment_variables_with_server($env->value, $this->application, $this->mainServer);
|
|
|
|
if ($env->is_literal && ! $this->has_secret_manager_source()) {
|
|
return $value ?? '';
|
|
}
|
|
|
|
return $this->substitute_remote_secrets($value ?? '', $env->key);
|
|
}
|
|
|
|
/**
|
|
* Without a source, a literal keeps "{{vault.KEY}}" as text; other references fail closed.
|
|
*/
|
|
private function uses_remote_secret_references(EnvironmentVariable $env): bool
|
|
{
|
|
if (! RemoteSecretReferences::containsReference($env->value)) {
|
|
return false;
|
|
}
|
|
|
|
return ! $env->is_literal || $this->has_secret_manager_source();
|
|
}
|
|
|
|
private function has_secret_manager_source(): bool
|
|
{
|
|
return $this->has_secret_manager_source ??= $this->remote_secrets_cache !== null
|
|
|| $this->application->secretManagerLink()->exists();
|
|
}
|
|
|
|
/**
|
|
* Resolve a runtime variable to its dotenv representation. Values with
|
|
* secret references are substituted and written as literals.
|
|
*/
|
|
private function resolve_environment_variable(EnvironmentVariable $env): ?string
|
|
{
|
|
if (! $this->uses_remote_secret_references($env)) {
|
|
return $env->getResolvedValueWithServer($this->mainServer);
|
|
}
|
|
|
|
return $this->format_remote_secret_value($this->resolve_environment_variable_raw($env));
|
|
}
|
|
|
|
/**
|
|
* Format a remote secret value for the runtime .env file (dotenv syntax read
|
|
* by docker compose). Values are treated as literals — no interpolation.
|
|
*/
|
|
private function format_remote_secret_value(string $value): string
|
|
{
|
|
return RemoteSecretValueFormatter::dotenv($value);
|
|
}
|
|
|
|
private function generate_runtime_environment_variables()
|
|
{
|
|
$envs = collect([]);
|
|
$sort = $this->application->settings->is_env_sorting_enabled;
|
|
if ($sort) {
|
|
$sorted_environment_variables = $this->application->runtime_environment_variables->sortBy('key');
|
|
$sorted_environment_variables_preview = $this->application->runtime_environment_variables_preview()->whereNotIn('id', $this->previewVariableIdsWithProductionValues())->get()->sortBy('key');
|
|
} else {
|
|
$sorted_environment_variables = $this->application->runtime_environment_variables->sortBy('id');
|
|
$sorted_environment_variables_preview = $this->application->runtime_environment_variables_preview()->whereNotIn('id', $this->previewVariableIdsWithProductionValues())->get()->sortBy('id');
|
|
}
|
|
if ($this->build_pack === 'dockercompose') {
|
|
$sorted_environment_variables = $sorted_environment_variables->reject(fn (EnvironmentVariable $env) => $this->isGeneratedDockerComposeEnvironmentVariable($env));
|
|
$sorted_environment_variables_preview = $sorted_environment_variables_preview->reject(fn (EnvironmentVariable $env) => $this->isGeneratedDockerComposeEnvironmentVariable($env));
|
|
}
|
|
$ports = $this->application->main_port();
|
|
$coolify_envs = $this->generate_coolify_env_variables();
|
|
$coolify_envs->each(function ($item, $key) use ($envs) {
|
|
$envs->push($key.'='.escapeComposeEnvFileValue($item));
|
|
});
|
|
if ($this->pull_request_id === 0) {
|
|
// Generate SERVICE_ variables first for dockercompose
|
|
if ($this->build_pack === 'dockercompose') {
|
|
foreach ($this->composeServiceUrlVariables($this->application->docker_compose_domains) as $key => $value) {
|
|
$envs->push($key.'='.$value);
|
|
}
|
|
|
|
// Generate SERVICE_NAME for dockercompose services from processed compose
|
|
if ($this->application->settings->is_raw_compose_deployment_enabled) {
|
|
$dockerCompose = parseDockerComposeYaml($this->application->docker_compose_raw);
|
|
} else {
|
|
$dockerCompose = parseDockerComposeYaml($this->application->docker_compose);
|
|
}
|
|
$services = data_get($dockerCompose, 'services', []);
|
|
foreach ($services as $serviceName => $_) {
|
|
$envs->push('SERVICE_NAME_'.str($serviceName)->replace('-', '_')->replace('.', '_')->upper().'='.$serviceName);
|
|
}
|
|
}
|
|
|
|
// Filter runtime variables (only include variables that are available at runtime)
|
|
$runtime_environment_variables = $sorted_environment_variables->filter(function ($env) {
|
|
return $env->is_runtime;
|
|
});
|
|
|
|
// Sort runtime environment variables: those referencing SERVICE_ variables come after others
|
|
$runtime_environment_variables = $runtime_environment_variables->sortBy(function ($env) {
|
|
if (str($env->value)->startsWith('$SERVICE_') || str($env->value)->contains('${SERVICE_')) {
|
|
return 2;
|
|
}
|
|
|
|
return 1;
|
|
});
|
|
|
|
foreach ($runtime_environment_variables as $env) {
|
|
if ($this->shouldOmitBlankComposeEnvironmentVariable($env)) {
|
|
continue;
|
|
}
|
|
|
|
$envs->push($this->runtimeEnvironmentAssignment($env));
|
|
}
|
|
|
|
// Check for PORT environment variable mismatch with ports_exposes.
|
|
// With readonly labels disabled, custom labels control proxy routing, so skip the check.
|
|
if ($this->build_pack !== 'dockercompose' && $this->application->settings->is_container_label_readonly_enabled) {
|
|
$detectedPort = $this->application->detectPortFromEnvironment(false);
|
|
if ($detectedPort && ! empty($ports) && ! in_array($detectedPort, $ports)) {
|
|
$this->application_deployment_queue->addLogEntry(
|
|
"Warning: PORT environment variable ({$detectedPort}) does not match configured ports_exposes: ".implode(',', $ports).'. It could case "bad gateway" or "no server" errors. Check the "General" page to fix it.',
|
|
'stderr'
|
|
);
|
|
}
|
|
}
|
|
|
|
// Add PORT if not exists, use the first port as default
|
|
if ($this->build_pack !== 'dockercompose') {
|
|
if ($this->application->environment_variables->where('key', 'PORT')->isEmpty() && ! empty($ports)) {
|
|
$envs->push("PORT={$ports[0]}");
|
|
}
|
|
}
|
|
// Add HOST if not exists
|
|
if ($this->application->environment_variables->where('key', 'HOST')->isEmpty()) {
|
|
$envs->push('HOST=0.0.0.0');
|
|
}
|
|
} else {
|
|
// Generate SERVICE_ variables first for dockercompose preview
|
|
if ($this->build_pack === 'dockercompose') {
|
|
foreach ($this->composeServiceUrlVariables(data_get($this->preview, 'docker_compose_domains')) as $key => $value) {
|
|
$envs->push($key.'='.$value);
|
|
}
|
|
|
|
// Generate SERVICE_NAME for dockercompose services
|
|
$rawDockerCompose = parseDockerComposeYaml($this->application->docker_compose_raw);
|
|
$rawServices = data_get($rawDockerCompose, 'services', []);
|
|
foreach ($rawServices as $rawServiceName => $_) {
|
|
$envs->push('SERVICE_NAME_'.str($rawServiceName)->replace('-', '_')->replace('.', '_')->upper().'='.addPreviewDeploymentSuffix($rawServiceName, $this->pull_request_id));
|
|
}
|
|
}
|
|
|
|
// Filter runtime variables for preview (only include variables that are available at runtime)
|
|
$runtime_environment_variables_preview = $sorted_environment_variables_preview->filter(function ($env) {
|
|
return $env->is_runtime;
|
|
});
|
|
|
|
// Sort runtime environment variables: those referencing SERVICE_ variables come after others
|
|
$runtime_environment_variables_preview = $runtime_environment_variables_preview->sortBy(function ($env) {
|
|
if (str($env->value)->startsWith('$SERVICE_') || str($env->value)->contains('${SERVICE_')) {
|
|
return 2;
|
|
}
|
|
|
|
return 1;
|
|
});
|
|
|
|
foreach ($runtime_environment_variables_preview as $env) {
|
|
if ($this->shouldOmitBlankComposeEnvironmentVariable($env)) {
|
|
continue;
|
|
}
|
|
|
|
$envs->push($this->runtimeEnvironmentAssignment($env));
|
|
}
|
|
|
|
// A preview from before v4.5.1 also gets the production variables that have no preview copy.
|
|
if ($this->preview?->keeps_production_values && $runtime_environment_variables_preview->isNotEmpty()) {
|
|
$previewKeys = $runtime_environment_variables_preview->pluck('key')->all();
|
|
$sorted_environment_variables
|
|
->filter(fn (EnvironmentVariable $env) => $env->is_runtime && ! in_array($env->key, $previewKeys, true) && ! $this->shouldOmitBlankComposeEnvironmentVariable($env))
|
|
->each(fn (EnvironmentVariable $env) => $envs->push($this->runtimeEnvironmentAssignment($env)));
|
|
}
|
|
|
|
// Add PORT if not exists, use the first port as default
|
|
if ($this->build_pack !== 'dockercompose') {
|
|
if ($this->deployedPreviewVariables()->where('key', 'PORT')->isEmpty()) {
|
|
$envs->push("PORT={$ports[0]}");
|
|
}
|
|
}
|
|
// Add HOST if not exists
|
|
if ($this->deployedPreviewVariables()->where('key', 'HOST')->isEmpty()) {
|
|
$envs->push('HOST=0.0.0.0');
|
|
}
|
|
}
|
|
|
|
// Return the generated environment variables instead of storing them globally
|
|
return $envs;
|
|
}
|
|
|
|
/**
|
|
* A KEY="value" line for the runtime .env file, which Docker Compose reads. Variables saved
|
|
* before exact escaping keep the old line, so their containers get the same value as before.
|
|
*/
|
|
private function runtimeEnvironmentAssignment(EnvironmentVariable $environmentVariable): string
|
|
{
|
|
if (! $this->application->useExactEscaping($environmentVariable)) {
|
|
return $environmentVariable->key.'='.$this->resolve_environment_variable($environmentVariable);
|
|
}
|
|
|
|
$resolvedValue = (string) $environmentVariable->get_real_environment_variables_with_server(
|
|
$environmentVariable->value,
|
|
$this->application,
|
|
$this->mainServer,
|
|
);
|
|
|
|
if (RemoteSecretReferences::containsReference($environmentVariable->value) || RemoteSecretReferences::containsReference($resolvedValue)) {
|
|
return $environmentVariable->key.'='.escapeComposeEnvFileValue($this->resolve_environment_variable_raw($environmentVariable));
|
|
}
|
|
|
|
return $environmentVariable->key.'='.escapeComposeEnvFileValue(
|
|
$resolvedValue,
|
|
allowInterpolation: $this->application->environmentVariableAllowsInterpolation($environmentVariable, $resolvedValue),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* SERVICE_URL_* and SERVICE_FQDN_* values from the compose domains. Port-suffixed variables
|
|
* that the parser stored (SERVICE_URL_APP_3000) get the same value with the port appended.
|
|
*
|
|
* @return array<string, string>
|
|
*/
|
|
private function composeServiceUrlVariables(?string $domainsJson): array
|
|
{
|
|
$storedKeys = $this->application->environment_variables()->pluck('key');
|
|
$variables = [];
|
|
foreach (json_decode($domainsJson ?: '[]', true) ?: [] as $forServiceName => $domain) {
|
|
$parsedDomain = composeDomainEntryString($domain);
|
|
if (blank($parsedDomain)) {
|
|
continue;
|
|
}
|
|
$url = Url::fromString(str($parsedDomain)->explode(',')->first())->withPort(null);
|
|
$values = ['URL' => $url->__toString(), 'FQDN' => $url->getHost()];
|
|
$serviceEnvKey = str(normalizeComposeServiceName((string) $forServiceName))->upper()->value();
|
|
foreach ($values as $type => $value) {
|
|
$variables["SERVICE_{$type}_{$serviceEnvKey}"] = $value;
|
|
}
|
|
$pattern = '/^SERVICE_(URL|FQDN)_'.preg_quote($serviceEnvKey, '/').'_(\d+)$/';
|
|
foreach ($storedKeys as $key) {
|
|
if (preg_match($pattern, $key, $matches)) {
|
|
$variables[$key] = "{$values[$matches[1]]}:{$matches[2]}";
|
|
}
|
|
}
|
|
}
|
|
|
|
return $variables;
|
|
}
|
|
|
|
private function isGeneratedDockerComposeEnvironmentVariable(EnvironmentVariable $environmentVariable): bool
|
|
{
|
|
$key = str($environmentVariable->key);
|
|
|
|
return $key->startsWith('SERVICE_FQDN_')
|
|
|| $key->startsWith('SERVICE_URL_')
|
|
|| $key->startsWith('SERVICE_NAME_');
|
|
}
|
|
|
|
private function shouldOmitBlankComposeEnvironmentVariable(EnvironmentVariable $environmentVariable): bool
|
|
{
|
|
return $this->build_pack === 'dockercompose' && ! $environmentVariable->preservesLegacyComposeNull() && dockerComposeEnvironmentVariableShouldBeUnset(
|
|
$this->application->docker_compose_raw ?? $this->application->docker_compose,
|
|
$environmentVariable->key,
|
|
$environmentVariable->value
|
|
);
|
|
}
|
|
|
|
private function save_runtime_environment_variables()
|
|
{
|
|
// This method saves the .env file with ALL runtime variables
|
|
// For builds, it should be called AFTER the build to include runtime-only variables
|
|
|
|
// Generate runtime environment variables locally
|
|
$environment_variables = $this->generate_runtime_environment_variables();
|
|
|
|
// Each .env write first removes the file: with "Preserve repository", `docker cp` can copy a symlink
|
|
// from the repository to this name. Coolify writes a regular file instead of the link target.
|
|
|
|
// Handle empty environment variables
|
|
if ($environment_variables->isEmpty()) {
|
|
// For Docker Compose and Docker Image, we need to create an empty .env file
|
|
// because we always reference it in the compose file
|
|
if ($this->build_pack === 'dockercompose' || $this->build_pack === 'dockerimage') {
|
|
$this->application_deployment_queue->addLogEntry('Creating empty .env file (no environment variables defined).');
|
|
|
|
// Create empty .env file
|
|
$this->execute_remote_command(
|
|
[
|
|
executeInDocker($this->deployment_uuid, "rm -f $this->workdir/.env && touch $this->workdir/.env"),
|
|
]
|
|
);
|
|
|
|
// Also create in configuration directory
|
|
if ($this->use_build_server) {
|
|
$this->server = $this->mainServer;
|
|
$this->execute_remote_command(
|
|
[
|
|
"rm -f $this->configuration_dir/.env",
|
|
'hidden' => true,
|
|
],
|
|
[
|
|
"touch $this->configuration_dir/.env",
|
|
]
|
|
);
|
|
$this->server = $this->build_server;
|
|
} else {
|
|
$this->execute_remote_command(
|
|
[
|
|
"rm -f $this->configuration_dir/.env",
|
|
'hidden' => true,
|
|
],
|
|
[
|
|
"touch $this->configuration_dir/.env",
|
|
]
|
|
);
|
|
}
|
|
} else {
|
|
// For non-Docker Compose deployments, clean up any existing .env files
|
|
if ($this->use_build_server) {
|
|
$this->server = $this->mainServer;
|
|
$this->execute_remote_command(
|
|
[
|
|
'command' => "rm -f $this->configuration_dir/.env",
|
|
'hidden' => true,
|
|
'ignore_errors' => true,
|
|
]
|
|
);
|
|
$this->server = $this->build_server;
|
|
$this->execute_remote_command(
|
|
[
|
|
'command' => "rm -f $this->configuration_dir/.env",
|
|
'hidden' => true,
|
|
'ignore_errors' => true,
|
|
]
|
|
);
|
|
} else {
|
|
$this->execute_remote_command(
|
|
[
|
|
'command' => "rm -f $this->configuration_dir/.env",
|
|
'hidden' => true,
|
|
'ignore_errors' => true,
|
|
]
|
|
);
|
|
}
|
|
}
|
|
|
|
return;
|
|
}
|
|
|
|
// Write the environment variables to file
|
|
$envs_base64 = base64_encode($environment_variables->implode("\n"));
|
|
|
|
// Write .env file to workdir (for container runtime)
|
|
$this->application_deployment_queue->addLogEntry('Creating .env file with runtime variables for container.', hidden: true);
|
|
$this->execute_remote_command(
|
|
[
|
|
executeInDocker($this->deployment_uuid, "rm -f $this->workdir/.env && echo '$envs_base64' | base64 -d | tee $this->workdir/.env > /dev/null"),
|
|
'skip_command_log' => true,
|
|
]
|
|
);
|
|
|
|
if (isDev()) {
|
|
$this->logEnvironmentFileKeys("{$this->workdir}/.env", $environment_variables);
|
|
}
|
|
|
|
// Write .env file to configuration directory
|
|
if ($this->use_build_server) {
|
|
$this->server = $this->mainServer;
|
|
$this->execute_remote_command(
|
|
[
|
|
"rm -f $this->configuration_dir/.env",
|
|
'hidden' => true,
|
|
],
|
|
[
|
|
"echo '$envs_base64' | base64 -d | tee $this->configuration_dir/.env > /dev/null",
|
|
'skip_command_log' => true,
|
|
]
|
|
);
|
|
$this->server = $this->build_server;
|
|
} else {
|
|
$this->execute_remote_command(
|
|
[
|
|
"rm -f $this->configuration_dir/.env",
|
|
'hidden' => true,
|
|
],
|
|
[
|
|
"echo '$envs_base64' | base64 -d | tee $this->configuration_dir/.env > /dev/null",
|
|
'skip_command_log' => true,
|
|
]
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Development aid: log which variables an env file contains, without their values.
|
|
*
|
|
* @param Collection<int, string> $environmentVariables Lines in KEY=VALUE format.
|
|
*/
|
|
private function logEnvironmentFileKeys(string $path, Collection $environmentVariables): void
|
|
{
|
|
$keys = $environmentVariables
|
|
->map(fn (string $line): string => explode('=', $line, 2)[0])
|
|
->implode(', ');
|
|
|
|
$this->application_deployment_queue->addLogEntry("[DEBUG] Variable names in {$path}: {$keys}", hidden: true);
|
|
}
|
|
|
|
private function generate_buildtime_environment_variables()
|
|
{
|
|
if (isDev()) {
|
|
$this->application_deployment_queue->addLogEntry('[DEBUG] ========================================');
|
|
$this->application_deployment_queue->addLogEntry('[DEBUG] Generating build-time environment variables');
|
|
$this->application_deployment_queue->addLogEntry('[DEBUG] ========================================');
|
|
}
|
|
|
|
// Use associative array for automatic deduplication
|
|
$envs_dict = [];
|
|
|
|
// 1. Add nixpacks plan variables FIRST (lowest priority - can be overridden)
|
|
if ($this->build_pack === 'nixpacks' &&
|
|
isset($this->nixpacks_plan_json) &&
|
|
$this->nixpacks_plan_json->isNotEmpty()) {
|
|
|
|
$planVariables = data_get($this->nixpacks_plan_json, 'variables', []);
|
|
|
|
if (! empty($planVariables)) {
|
|
if (isDev()) {
|
|
$this->application_deployment_queue->addLogEntry('[DEBUG] Adding '.count($planVariables).' nixpacks plan variables to buildtime.env');
|
|
}
|
|
|
|
foreach ($planVariables as $key => $value) {
|
|
$key = (string) $key;
|
|
|
|
// Skip COOLIFY_* and SERVICE_* - they'll be added later with higher priority
|
|
if (str_starts_with($key, 'COOLIFY_') || str_starts_with($key, 'SERVICE_')) {
|
|
continue;
|
|
}
|
|
|
|
$key = $this->validatedBuildtimeEnvironmentVariableKey($key, 'the Nixpacks plan');
|
|
$escapedValue = escapeBashEnvValue($value);
|
|
$envs_dict[$key] = $escapedValue;
|
|
|
|
if (isDev()) {
|
|
$this->application_deployment_queue->addLogEntry("[DEBUG] Nixpacks var: {$key}");
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// 2. Add COOLIFY variables (can override nixpacks, but shouldn't happen in practice)
|
|
$coolify_envs = $this->generate_coolify_env_variables(forBuildTime: true);
|
|
foreach ($coolify_envs as $key => $item) {
|
|
$envs_dict[$key] = escapeBashEnvValue($item);
|
|
}
|
|
|
|
// 3. Add SERVICE_NAME, SERVICE_FQDN, SERVICE_URL variables for Docker Compose builds
|
|
if ($this->build_pack === 'dockercompose') {
|
|
if ($this->pull_request_id === 0) {
|
|
// Generate SERVICE_NAME for dockercompose services from processed compose
|
|
if ($this->application->settings->is_raw_compose_deployment_enabled) {
|
|
$dockerCompose = parseDockerComposeYaml($this->application->docker_compose_raw);
|
|
} else {
|
|
$dockerCompose = parseDockerComposeYaml($this->application->docker_compose);
|
|
}
|
|
$services = data_get($dockerCompose, 'services', []);
|
|
foreach ($services as $serviceName => $_) {
|
|
$envs_dict['SERVICE_NAME_'.str($serviceName)->replace('-', '_')->replace('.', '_')->upper()] = escapeBashEnvValue($serviceName);
|
|
}
|
|
|
|
// Generate SERVICE_FQDN & SERVICE_URL for non-PR deployments
|
|
foreach ($this->composeServiceUrlVariables($this->application->docker_compose_domains) as $key => $value) {
|
|
$envs_dict[$key] = escapeBashEnvValue($value);
|
|
}
|
|
} else {
|
|
// Generate SERVICE_NAME for preview deployments
|
|
$rawDockerCompose = parseDockerComposeYaml($this->application->docker_compose_raw);
|
|
$rawServices = data_get($rawDockerCompose, 'services', []);
|
|
foreach ($rawServices as $rawServiceName => $_) {
|
|
$envs_dict['SERVICE_NAME_'.str($rawServiceName)->replace('-', '_')->replace('.', '_')->upper()] = escapeBashEnvValue(addPreviewDeploymentSuffix($rawServiceName, $this->pull_request_id));
|
|
}
|
|
|
|
// Generate SERVICE_FQDN & SERVICE_URL for preview deployments with PR-specific domains
|
|
foreach ($this->composeServiceUrlVariables(data_get($this->preview, 'docker_compose_domains')) as $key => $value) {
|
|
$envs_dict[$key] = escapeBashEnvValue($value);
|
|
}
|
|
}
|
|
}
|
|
|
|
// 4. Add user-defined build-time variables LAST (highest priority - can override everything)
|
|
if ($this->pull_request_id === 0) {
|
|
$sorted_environment_variables = $this->application->environment_variables()
|
|
->withoutBuildpackControlVariables()
|
|
->where('is_buildtime', true) // ONLY build-time variables
|
|
->orderBy($this->application->settings->is_env_sorting_enabled ? 'key' : 'id')
|
|
->get();
|
|
$sorted_environment_variables = $this->withoutLegacyEnvironmentVariableKeys($sorted_environment_variables);
|
|
|
|
// For Docker Compose, filter out generated SERVICE_* variables as we generate these
|
|
if ($this->build_pack === 'dockercompose') {
|
|
$sorted_environment_variables = $sorted_environment_variables->reject(fn (EnvironmentVariable $env) => $this->isGeneratedDockerComposeEnvironmentVariable($env));
|
|
}
|
|
|
|
foreach ($sorted_environment_variables as $env) {
|
|
if ($this->shouldOmitBlankComposeEnvironmentVariable($env)) {
|
|
continue;
|
|
}
|
|
|
|
if ($this->build_pack === 'railpack' && $this->is_reserved_docker_client_env_key($env->key)) {
|
|
continue;
|
|
}
|
|
|
|
if ($this->uses_remote_secret_references($env)) {
|
|
$envs_dict[$env->key] = escapeBashEnvValue($this->resolve_environment_variable_raw($env));
|
|
|
|
continue;
|
|
}
|
|
|
|
$resolvedValue = $env->getResolvedValueWithServer($this->mainServer);
|
|
// For literal/multiline vars, real_value includes quotes that we need to remove
|
|
if ($env->is_literal || $env->is_multiline) {
|
|
// Strip outer quotes from real_value and apply proper bash escaping
|
|
$value = trim($resolvedValue, "'");
|
|
$escapedValue = escapeBashEnvValue($value);
|
|
|
|
if (isDev() && isset($envs_dict[$env->key])) {
|
|
$this->application_deployment_queue->addLogEntry("[DEBUG] User override: {$env->key} (value replaced)");
|
|
}
|
|
|
|
$envs_dict[$env->key] = $escapedValue;
|
|
|
|
if (isDev()) {
|
|
$this->application_deployment_queue->addLogEntry("[DEBUG] Build-time env: {$env->key}");
|
|
$this->application_deployment_queue->addLogEntry('[DEBUG] Type: literal/multiline');
|
|
$this->application_deployment_queue->addLogEntry('[DEBUG] Value length: '.strlen($escapedValue));
|
|
}
|
|
} else {
|
|
// For normal vars, use double quotes to allow $VAR expansion
|
|
$escapedValue = escapeBashDoubleQuoted($resolvedValue);
|
|
|
|
if (isDev() && isset($envs_dict[$env->key])) {
|
|
$this->application_deployment_queue->addLogEntry("[DEBUG] User override: {$env->key} (value replaced)");
|
|
}
|
|
|
|
$envs_dict[$env->key] = $escapedValue;
|
|
|
|
if (isDev()) {
|
|
$this->application_deployment_queue->addLogEntry("[DEBUG] Build-time env: {$env->key}");
|
|
$this->application_deployment_queue->addLogEntry('[DEBUG] Type: normal (allows expansion)');
|
|
$this->application_deployment_queue->addLogEntry('[DEBUG] Value length: '.strlen($escapedValue));
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
$sorted_environment_variables = $this->application->environment_variables_preview()
|
|
->whereNotIn('id', $this->previewVariableIdsWithProductionValues())
|
|
->withoutBuildpackControlVariables()
|
|
->where('is_buildtime', true) // ONLY build-time variables
|
|
->orderBy($this->application->settings->is_env_sorting_enabled ? 'key' : 'id')
|
|
->get();
|
|
$sorted_environment_variables = $this->withoutLegacyEnvironmentVariableKeys($sorted_environment_variables);
|
|
|
|
// For Docker Compose, filter out generated SERVICE_* variables as we generate these with PR-specific values
|
|
if ($this->build_pack === 'dockercompose') {
|
|
$sorted_environment_variables = $sorted_environment_variables->reject(fn (EnvironmentVariable $env) => $this->isGeneratedDockerComposeEnvironmentVariable($env));
|
|
}
|
|
|
|
foreach ($sorted_environment_variables as $env) {
|
|
if ($this->shouldOmitBlankComposeEnvironmentVariable($env)) {
|
|
continue;
|
|
}
|
|
|
|
if ($this->build_pack === 'railpack' && $this->is_reserved_docker_client_env_key($env->key)) {
|
|
continue;
|
|
}
|
|
|
|
if ($this->uses_remote_secret_references($env)) {
|
|
$envs_dict[$env->key] = escapeBashEnvValue($this->resolve_environment_variable_raw($env));
|
|
|
|
continue;
|
|
}
|
|
|
|
$resolvedValue = $env->getResolvedValueWithServer($this->mainServer);
|
|
// For literal/multiline vars, real_value includes quotes that we need to remove
|
|
if ($env->is_literal || $env->is_multiline) {
|
|
// Strip outer quotes from real_value and apply proper bash escaping
|
|
$value = trim($resolvedValue, "'");
|
|
$escapedValue = escapeBashEnvValue($value);
|
|
|
|
if (isDev() && isset($envs_dict[$env->key])) {
|
|
$this->application_deployment_queue->addLogEntry("[DEBUG] User override: {$env->key} (value replaced)");
|
|
}
|
|
|
|
$envs_dict[$env->key] = $escapedValue;
|
|
|
|
if (isDev()) {
|
|
$this->application_deployment_queue->addLogEntry("[DEBUG] Build-time env: {$env->key}");
|
|
$this->application_deployment_queue->addLogEntry('[DEBUG] Type: literal/multiline');
|
|
$this->application_deployment_queue->addLogEntry('[DEBUG] Value length: '.strlen($escapedValue));
|
|
}
|
|
} else {
|
|
// For normal vars, use double quotes to allow $VAR expansion
|
|
$escapedValue = escapeBashDoubleQuoted($resolvedValue);
|
|
|
|
if (isDev() && isset($envs_dict[$env->key])) {
|
|
$this->application_deployment_queue->addLogEntry("[DEBUG] User override: {$env->key} (value replaced)");
|
|
}
|
|
|
|
$envs_dict[$env->key] = $escapedValue;
|
|
|
|
if (isDev()) {
|
|
$this->application_deployment_queue->addLogEntry("[DEBUG] Build-time env: {$env->key}");
|
|
$this->application_deployment_queue->addLogEntry('[DEBUG] Type: normal (allows expansion)');
|
|
$this->application_deployment_queue->addLogEntry('[DEBUG] Value length: '.strlen($escapedValue));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Convert dictionary back to collection in KEY=VALUE format
|
|
$envs = collect([]);
|
|
foreach ($envs_dict as $key => $value) {
|
|
$key = $this->validatedBuildtimeEnvironmentVariableKey((string) $key, 'the build-time environment');
|
|
$envs->push($key.'='.$value);
|
|
}
|
|
|
|
// Return the generated environment variables
|
|
if (isDev()) {
|
|
$this->application_deployment_queue->addLogEntry('[DEBUG] ========================================');
|
|
$this->application_deployment_queue->addLogEntry("[DEBUG] Total build-time env variables: {$envs->count()}");
|
|
$this->application_deployment_queue->addLogEntry('[DEBUG] ========================================');
|
|
}
|
|
|
|
return $envs;
|
|
}
|
|
|
|
private function validatedBuildtimeEnvironmentVariableKey(string $key, string $origin): string
|
|
{
|
|
|
|
try {
|
|
if (! ValidationPatterns::isValidEnvironmentVariableKey($key)) {
|
|
throw new \InvalidArgumentException('Invalid build-time environment variable key.');
|
|
}
|
|
|
|
return $key;
|
|
} catch (\InvalidArgumentException $exception) {
|
|
$this->logInvalidBuildtimeEnvironmentVariableKey($key, $origin);
|
|
|
|
// The log already explains the problem and how to fix it.
|
|
throw DeploymentException::alreadyLogged(
|
|
"Invalid environment variable name from {$origin}: ".ValidationPatterns::displayShellEnvironmentVariableKey($key).'. Names must start with a letter or underscore and contain only letters, numbers, underscores, and dots.',
|
|
previous: $exception,
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Coolify blocks new volumes on multi-server applications, but older applications can have both.
|
|
* Some applications accept separate data on each server (for example a cache), so the
|
|
* deployment continues and only shows a warning.
|
|
*/
|
|
private function warnAboutVolumesOnMultipleServers(): void
|
|
{
|
|
if ($this->pull_request_id !== 0) {
|
|
return;
|
|
}
|
|
if ($this->application->additional_servers()->doesntExist() || $this->application->persistentStorages()->doesntExist()) {
|
|
return;
|
|
}
|
|
|
|
$this->application_deployment_queue->addLogEntry('Warning: This application uses multiple servers and has persistent storage. Volumes are not shared between servers, so each server has its own data.', 'stderr');
|
|
}
|
|
|
|
/**
|
|
* Servers that were added before the proxy check can still use another proxy than the primary server.
|
|
*/
|
|
private function warnAboutMixedProxiesOnMultipleServers(): void
|
|
{
|
|
if ($this->pull_request_id !== 0) {
|
|
return;
|
|
}
|
|
|
|
foreach ($this->application->additional_servers as $server) {
|
|
if ($reason = $this->application->proxyMismatchReason($server)) {
|
|
$this->application_deployment_queue->addLogEntry("Warning: {$reason} The domains of this application will not work through the proxy on {$server->name}.", 'stderr');
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Additional servers pull the image that the main server pushes. Without a registry image, each
|
|
* server builds its own image, so the servers can run different code.
|
|
*/
|
|
private function ensureRegistryImageForMultipleServers(): void
|
|
{
|
|
if ($this->pull_request_id !== 0 || $this->application->build_pack === 'dockercompose') {
|
|
return;
|
|
}
|
|
if ($this->application->additional_servers()->doesntExist()) {
|
|
return;
|
|
}
|
|
if (str($this->application->docker_registry_image_name)->isNotEmpty()) {
|
|
return;
|
|
}
|
|
|
|
throw new DeploymentException('Before deploying to multiple servers, you must set a Docker image in the General tab. More information: https://coolify.io/docs/knowledge-base/server/multiple-servers');
|
|
}
|
|
|
|
/**
|
|
* Two applications that build the same Docker image name overwrite each other's image tags.
|
|
*/
|
|
private function ensureRegistryImageIsNotShared(): void
|
|
{
|
|
$otherApplication = $this->application->otherApplicationBuildingSameImage();
|
|
if ($otherApplication) {
|
|
throw new DeploymentException($this->application->sharedImageNameMessage($otherApplication));
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Build-time names go into shell and Docker build commands, so they must be valid when the
|
|
* deployment builds an image ($buildsImage, checked right before the build steps). Restarts and
|
|
* deployments that reuse an existing image do not pass them to a build, so handle() only warns.
|
|
* Runtime-only variables only go into the .env file: existing ones with names that new variables
|
|
* can no longer use (such as my-var) keep working, unless the name would break a .env line.
|
|
* Deployments without a build step (Docker image) never validate build-time names.
|
|
*/
|
|
private function validateDeploymentEnvironmentVariableKeys(bool $buildsImage = false): void
|
|
{
|
|
$environmentVariables = $this->pull_request_id === 0
|
|
? $this->application->environment_variables()->get(['key', 'is_buildtime', 'is_runtime'])
|
|
: $this->application->environment_variables_preview()->get(['key', 'is_buildtime', 'is_runtime']);
|
|
$passesBuildtimeVariables = $this->deploymentPassesBuildtimeVariables();
|
|
|
|
foreach ($environmentVariables as $environmentVariable) {
|
|
$key = (string) $environmentVariable->key;
|
|
if (ValidationPatterns::isValidEnvironmentVariableKey($key)) {
|
|
continue;
|
|
}
|
|
|
|
$isEnvFileSafe = $key !== '' && strpbrk($key, "=\n\r\0") === false;
|
|
$isLegacyBuildtimeKey = preg_match('/\A[A-Za-z0-9_.-]+\z/', $key) === 1;
|
|
$isPassedToBuild = $environmentVariable->is_buildtime && $passesBuildtimeVariables && $buildsImage;
|
|
if (! $isEnvFileSafe || ($isPassedToBuild && ! $isLegacyBuildtimeKey)) {
|
|
$this->validatedBuildtimeEnvironmentVariableKey($key, 'the deployment environment');
|
|
}
|
|
|
|
$this->logLegacyEnvironmentVariableKey($key, (bool) $environmentVariable->is_buildtime, (bool) $environmentVariable->is_runtime, $passesBuildtimeVariables);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Only Docker image deployments never build an image. Other deployments (also restarts,
|
|
* rollbacks, and previews) can build and pass build-time variables to the build.
|
|
*/
|
|
private function deploymentPassesBuildtimeVariables(): bool
|
|
{
|
|
return $this->application->build_pack !== 'dockerimage';
|
|
}
|
|
|
|
private function logLegacyEnvironmentVariableKey(string $key, bool $isBuildtime, bool $isRuntime, bool $passesBuildtimeVariables): void
|
|
{
|
|
$suggestedKey = (string) preg_replace('/[^A-Za-z0-9_]/', '_', $key);
|
|
if (preg_match('/\A[0-9]/', $suggestedKey) === 1) {
|
|
$suggestedKey = '_'.$suggestedKey;
|
|
}
|
|
|
|
$displayKey = ValidationPatterns::displayShellEnvironmentVariableKey($key);
|
|
|
|
if ($isBuildtime && $passesBuildtimeVariables) {
|
|
$this->application_deployment_queue->addLogEntry("⚠️ Build-time variable {$displayKey} uses a name that new variables cannot use. It is skipped during the build".($isRuntime ? ', but is still passed to the container.' : '.'), 'stderr');
|
|
} else {
|
|
if ($isBuildtime) {
|
|
$this->application_deployment_queue->addLogEntry("⚠️ Build-time variable {$displayKey} uses a name that new variables cannot use. This deployment does not build an image, so it is not used as a build-time variable. Rename it before you use it in a build.", 'stderr');
|
|
}
|
|
if ($isRuntime) {
|
|
$this->application_deployment_queue->addLogEntry("⚠️ Runtime variable {$displayKey} uses a name that new variables cannot use. It is still passed to the container, but shell scripts cannot read it.", 'stderr');
|
|
}
|
|
if (! $isBuildtime && ! $isRuntime) {
|
|
$this->application_deployment_queue->addLogEntry("⚠️ Variable {$displayKey} uses a name that new variables cannot use. This deployment does not use it.", 'stderr');
|
|
}
|
|
}
|
|
$this->application_deployment_queue->addLogEntry(' Suggested name: '.ValidationPatterns::displayShellEnvironmentVariableKey($suggestedKey), type: 'info');
|
|
}
|
|
|
|
/**
|
|
* IDs of preview variables that have the same value as the production variable with the same key.
|
|
* Coolify copies each new production variable to the previews. With public PR deployments, a
|
|
* preview can run code from a fork, so it gets only the preview values that the user changed.
|
|
*
|
|
* @return array<int, int>
|
|
*/
|
|
private function previewVariableIdsWithProductionValues(): array
|
|
{
|
|
if ($this->previewVariableIdsWithProductionValues !== null) {
|
|
return $this->previewVariableIdsWithProductionValues;
|
|
}
|
|
|
|
if ($this->pull_request_id === 0 || ! $this->application->settings->is_pr_deployments_public_enabled || $this->preview?->keeps_production_values) {
|
|
return $this->previewVariableIdsWithProductionValues = [];
|
|
}
|
|
|
|
$productionValues = $this->application->environment_variables()->get()->pluck('value', 'key');
|
|
|
|
return $this->previewVariableIdsWithProductionValues = $this->application->environment_variables_preview()->get()
|
|
->filter(fn (EnvironmentVariable $env) => $productionValues->has($env->key) && $productionValues->get($env->key) === $env->value)
|
|
->pluck('id')
|
|
->all();
|
|
}
|
|
|
|
/**
|
|
* The preview variables that this preview deployment uses.
|
|
*/
|
|
private function deployedPreviewVariables(): Collection
|
|
{
|
|
return $this->application->environment_variables_preview->whereNotIn('id', $this->previewVariableIdsWithProductionValues());
|
|
}
|
|
|
|
/**
|
|
* Skip build-time variables with names that new variables can no longer use (such as my-var).
|
|
* validateDeploymentEnvironmentVariableKeys() logs a warning for them before the deployment starts.
|
|
*/
|
|
private function withoutLegacyEnvironmentVariableKeys(Collection $environmentVariables): Collection
|
|
{
|
|
return $environmentVariables->filter(
|
|
fn (EnvironmentVariable $environmentVariable): bool => ValidationPatterns::isValidEnvironmentVariableKey((string) $environmentVariable->key)
|
|
);
|
|
}
|
|
|
|
private function logInvalidBuildtimeEnvironmentVariableKey(string $key, string $origin): void
|
|
{
|
|
$displayKey = ValidationPatterns::displayShellEnvironmentVariableKey($key);
|
|
|
|
$this->application_deployment_queue->addLogEntry('----------------------------------------', 'stderr');
|
|
$this->application_deployment_queue->addLogEntry("⚠️ Invalid environment variable name from {$origin}: {$displayKey}", 'stderr');
|
|
$this->application_deployment_queue->addLogEntry('Build-time variable names must start with a letter or underscore and contain only letters, numbers, underscores, and dots.', 'stderr');
|
|
$this->application_deployment_queue->addLogEntry('💡 How to fix:', type: 'info');
|
|
|
|
if ($origin === 'the Nixpacks plan') {
|
|
$this->application_deployment_queue->addLogEntry(' 1. Open nixpacks.toml and check the [variables] section. Quoted keys can contain characters that are not valid environment variable names.', type: 'info');
|
|
$this->application_deployment_queue->addLogEntry(' 2. Rename the key to a plain name like MY_VARIABLE (no spaces, shell syntax, or command substitutions).', type: 'info');
|
|
$this->logSuggestedShellEnvironmentVariableKey($key);
|
|
$this->application_deployment_queue->addLogEntry(' 3. Commit, push, and redeploy.', type: 'info');
|
|
$this->application_deployment_queue->addLogEntry('Docs: https://nixpacks.com/docs/configuration/file', type: 'info');
|
|
} else {
|
|
$this->application_deployment_queue->addLogEntry(' Rename the environment variable to use only letters, numbers, and underscores, then redeploy.', type: 'info');
|
|
$this->logSuggestedShellEnvironmentVariableKey($key);
|
|
}
|
|
|
|
$this->application_deployment_queue->addLogEntry('----------------------------------------', 'stderr');
|
|
}
|
|
|
|
private function logSuggestedShellEnvironmentVariableKey(string $key): void
|
|
{
|
|
$suggestedKey = str_replace('.', '_', $key);
|
|
if ($suggestedKey === $key || preg_match(ValidationPatterns::SHELL_ENVIRONMENT_VARIABLE_KEY_PATTERN, $suggestedKey) !== 1) {
|
|
return;
|
|
}
|
|
|
|
$displaySuggestedKey = ValidationPatterns::displayShellEnvironmentVariableKey($suggestedKey);
|
|
|
|
$this->application_deployment_queue->addLogEntry(" Suggested name: {$displaySuggestedKey}", type: 'info');
|
|
}
|
|
|
|
/**
|
|
* Remove variables with dotted names from a Nixpacks or Railpack build.
|
|
*
|
|
* Nixpacks and Railpack cut these names at the first dot, and their Debian-based
|
|
* images run build commands through dash, which drops dotted names from the
|
|
* environment. The variables are still available at runtime.
|
|
*/
|
|
private function withoutDottedBuildVariables(Collection $variables, string $builder): Collection
|
|
{
|
|
$dottedKeys = $variables->keys()
|
|
->map(fn ($key): string => (string) $key)
|
|
->filter(fn (string $key): bool => str_contains($key, '.'))
|
|
->values();
|
|
|
|
if ($dottedKeys->isEmpty()) {
|
|
return $variables;
|
|
}
|
|
|
|
foreach ($dottedKeys as $key) {
|
|
$this->application_deployment_queue->addLogEntry('⚠️ Build-time variable '.ValidationPatterns::displayShellEnvironmentVariableKey($key)." has a dot in its name, which {$builder} cannot pass to the build. It is skipped during the build, but is still passed to the container.", 'stderr');
|
|
$this->logSuggestedShellEnvironmentVariableKey($key);
|
|
}
|
|
$this->application_deployment_queue->addLogEntry(' If the build needs the value, rename the variable. Otherwise, disable "Available at Buildtime" for it.', type: 'info');
|
|
|
|
return $variables->reject(fn ($value, $key): bool => str_contains((string) $key, '.'));
|
|
}
|
|
|
|
private function save_buildtime_environment_variables(?Collection $environment_variables = null)
|
|
{
|
|
$environment_variables ??= $this->generate_buildtime_environment_variables();
|
|
[$shell_environment_variables, $dotted_environment_variables] = $environment_variables->partition(function (string $environmentVariable): bool {
|
|
[$key] = explode('=', $environmentVariable, 2);
|
|
|
|
return preg_match(ValidationPatterns::SHELL_ENVIRONMENT_VARIABLE_KEY_PATTERN, $key) === 1;
|
|
});
|
|
|
|
if ($dotted_environment_variables->isEmpty()) {
|
|
$this->useBuildtimeEnvironmentLauncher = false;
|
|
|
|
if ($environment_variables->isNotEmpty()) {
|
|
$envs_base64 = base64_encode($environment_variables->implode("\n"));
|
|
|
|
$this->application_deployment_queue->addLogEntry('Creating build-time .env file in /artifacts (outside Docker context).', hidden: true);
|
|
$this->execute_remote_command([
|
|
executeInDocker($this->deployment_uuid, "echo '$envs_base64' | base64 -d | tee ".self::BUILD_TIME_ENV_PATH.' > /dev/null'),
|
|
'skip_command_log' => true,
|
|
]);
|
|
|
|
if (isDev()) {
|
|
$this->logEnvironmentFileKeys(self::BUILD_TIME_ENV_PATH, $environment_variables);
|
|
}
|
|
} elseif (in_array($this->build_pack, ['dockercompose', 'dockerfile', 'railpack'], true)) {
|
|
$this->application_deployment_queue->addLogEntry('Creating empty build-time .env file in /artifacts (no build-time variables defined).', hidden: true);
|
|
$this->execute_remote_command([
|
|
executeInDocker($this->deployment_uuid, 'touch '.self::BUILD_TIME_ENV_PATH),
|
|
]);
|
|
}
|
|
|
|
return;
|
|
}
|
|
|
|
$this->useBuildtimeEnvironmentLauncher = true;
|
|
|
|
$launcher = [
|
|
'#!/bin/bash',
|
|
'set -a',
|
|
'source '.self::BUILD_TIME_SHELL_ENV_PATH,
|
|
'set +a',
|
|
];
|
|
|
|
$launcher[] = 'exec env \\';
|
|
foreach ($dotted_environment_variables as $environmentVariable) {
|
|
$launcher[] = " {$environmentVariable} \\";
|
|
}
|
|
$launcher[] = ' "$@"';
|
|
|
|
$files = [
|
|
self::BUILD_TIME_ENV_PATH => $environment_variables->implode("\n"),
|
|
self::BUILD_TIME_SHELL_ENV_PATH => $shell_environment_variables->implode("\n"),
|
|
self::BUILD_TIME_ENV_LAUNCHER_PATH => implode("\n", $launcher)."\n",
|
|
];
|
|
|
|
$this->application_deployment_queue->addLogEntry('Creating build-time environment files in /artifacts (outside Docker context).', hidden: true);
|
|
|
|
foreach ($files as $path => $contents) {
|
|
$contents_base64 = base64_encode($contents);
|
|
$this->execute_remote_command([
|
|
executeInDocker($this->deployment_uuid, "echo '$contents_base64' | base64 -d | tee {$path} > /dev/null"),
|
|
'skip_command_log' => true,
|
|
]);
|
|
}
|
|
|
|
$this->execute_remote_command([
|
|
executeInDocker($this->deployment_uuid, 'chmod 700 '.self::BUILD_TIME_ENV_LAUNCHER_PATH),
|
|
]);
|
|
}
|
|
|
|
private function elixir_finetunes()
|
|
{
|
|
if ($this->pull_request_id === 0) {
|
|
$envType = 'environment_variables';
|
|
} else {
|
|
$envType = 'environment_variables_preview';
|
|
}
|
|
$mix_env = $this->application->{$envType}->where('key', 'MIX_ENV')->first();
|
|
if (! $mix_env) {
|
|
$this->application_deployment_queue->addLogEntry('MIX_ENV environment variable not found.', type: 'error');
|
|
$this->application_deployment_queue->addLogEntry('Please add MIX_ENV environment variable and set it to be build time variable if you facing any issues with the deployment.', type: 'error');
|
|
}
|
|
$secret_key_base = $this->application->{$envType}->where('key', 'SECRET_KEY_BASE')->first();
|
|
if (! $secret_key_base) {
|
|
$this->application_deployment_queue->addLogEntry('SECRET_KEY_BASE environment variable not found.', type: 'error');
|
|
$this->application_deployment_queue->addLogEntry('Please add SECRET_KEY_BASE environment variable and set it to be build time variable if you facing any issues with the deployment.', type: 'error');
|
|
}
|
|
$database_url = $this->application->{$envType}->where('key', 'DATABASE_URL')->first();
|
|
if (! $database_url) {
|
|
$this->application_deployment_queue->addLogEntry('DATABASE_URL environment variable not found.', type: 'error');
|
|
$this->application_deployment_queue->addLogEntry('Please add DATABASE_URL environment variable and set it to be build time variable if you facing any issues with the deployment.', type: 'error');
|
|
}
|
|
}
|
|
|
|
private function laravel_finetunes()
|
|
{
|
|
if ($this->pull_request_id === 0) {
|
|
$envType = 'environment_variables';
|
|
} else {
|
|
$envType = 'environment_variables_preview';
|
|
}
|
|
$nixpacks_php_fallback_path = $this->application->{$envType}->where('key', 'NIXPACKS_PHP_FALLBACK_PATH')->first();
|
|
$nixpacks_php_root_dir = $this->application->{$envType}->where('key', 'NIXPACKS_PHP_ROOT_DIR')->first();
|
|
|
|
if (! $nixpacks_php_fallback_path) {
|
|
$nixpacks_php_fallback_path = new EnvironmentVariable;
|
|
$nixpacks_php_fallback_path->key = 'NIXPACKS_PHP_FALLBACK_PATH';
|
|
$nixpacks_php_fallback_path->value = '/index.php';
|
|
$nixpacks_php_fallback_path->resourceable_id = $this->application->id;
|
|
$nixpacks_php_fallback_path->resourceable_type = 'App\Models\Application';
|
|
$nixpacks_php_fallback_path->is_preview = $this->pull_request_id !== 0;
|
|
$nixpacks_php_fallback_path->save();
|
|
}
|
|
if (! $nixpacks_php_root_dir) {
|
|
$nixpacks_php_root_dir = new EnvironmentVariable;
|
|
$nixpacks_php_root_dir->key = 'NIXPACKS_PHP_ROOT_DIR';
|
|
$nixpacks_php_root_dir->value = '/app/public';
|
|
$nixpacks_php_root_dir->resourceable_id = $this->application->id;
|
|
$nixpacks_php_root_dir->resourceable_type = 'App\Models\Application';
|
|
$nixpacks_php_root_dir->is_preview = $this->pull_request_id !== 0;
|
|
$nixpacks_php_root_dir->save();
|
|
}
|
|
|
|
return [$nixpacks_php_fallback_path, $nixpacks_php_root_dir];
|
|
}
|
|
|
|
private function rolling_update()
|
|
{
|
|
try {
|
|
$this->startDeployPhase();
|
|
$this->write_compose_file_to_workdir();
|
|
if ($this->server->isSwarm()) {
|
|
$this->application_deployment_queue->addLogEntry('Rolling update started.');
|
|
$this->execute_remote_command(
|
|
[
|
|
executeInDocker($this->deployment_uuid, "docker stack deploy --detach=true --with-registry-auth -c {$this->workdir}{$this->docker_compose_location} {$this->application->uuid}"),
|
|
],
|
|
);
|
|
$this->application_deployment_queue->addLogEntry('Rolling update completed.');
|
|
} else {
|
|
if ($this->use_build_server) {
|
|
$this->write_deployment_configurations();
|
|
$this->server = $this->mainServer;
|
|
}
|
|
if (count($this->application->ports_mappings_array) > 0 || (bool) $this->application->settings->is_consistent_container_name_enabled || $this->pull_request_id !== 0 || str($this->application->custom_docker_run_options)->contains('--ip') || str($this->application->custom_docker_run_options)->contains('--ip6')) {
|
|
$this->application_deployment_queue->addLogEntry('----------------------------------------');
|
|
if (count($this->application->ports_mappings_array) > 0) {
|
|
$this->application_deployment_queue->addLogEntry('Application has ports mapped to the host system, rolling update is not supported.');
|
|
}
|
|
if ((bool) $this->application->settings->is_consistent_container_name_enabled) {
|
|
$this->application_deployment_queue->addLogEntry('Consistent container name feature enabled, rolling update is not supported.');
|
|
}
|
|
if ((bool) $this->application->settings->is_consistent_container_name_enabled && str($this->application->settings->custom_internal_name)->isNotEmpty()) {
|
|
$this->application_deployment_queue->addLogEntry('Custom internal name is set, rolling update is not supported.');
|
|
}
|
|
if ($this->pull_request_id !== 0) {
|
|
$this->application->settings->is_consistent_container_name_enabled = true;
|
|
$this->application_deployment_queue->addLogEntry('Pull request deployment, rolling update is not supported.');
|
|
}
|
|
if (str($this->application->custom_docker_run_options)->contains('--ip') || str($this->application->custom_docker_run_options)->contains('--ip6')) {
|
|
$this->application_deployment_queue->addLogEntry('Custom IP address is set, rolling update is not supported.');
|
|
}
|
|
$this->stop_running_container(force: true);
|
|
$this->start_by_compose_file();
|
|
} else {
|
|
$this->application_deployment_queue->addLogEntry('----------------------------------------');
|
|
$this->application_deployment_queue->addLogEntry('Rolling update started.');
|
|
$this->containerNamesBeforeRollingUpdate = getCurrentApplicationContainerStatus($this->server, $this->application, $this->pull_request_id)
|
|
->pluck('Names')
|
|
->all();
|
|
$this->start_by_compose_file();
|
|
$this->health_check();
|
|
$this->wait_for_proxy_to_route_new_container();
|
|
$this->stop_running_container();
|
|
$this->application_deployment_queue->addLogEntry('Rolling update completed.');
|
|
}
|
|
}
|
|
} catch (Exception $e) {
|
|
throw new DeploymentException('Rolling update failed ('.get_class($e).'): '.$e->getMessage(), $e->getCode(), $e);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* The proxy adds the new container some seconds after it is healthy. Stopping the old
|
|
* container before that sends requests to a closed port (502), see #8627.
|
|
*/
|
|
private function wait_for_proxy_to_route_new_container(): void
|
|
{
|
|
$seconds = $this->application->settings->rollingUpdateDrainSeconds();
|
|
if ($seconds === 0 || ! $this->newVersionIsHealthy()) {
|
|
return;
|
|
}
|
|
$oldContainerNames = getCurrentApplicationContainerStatus($this->server, $this->application, $this->pull_request_id)
|
|
->filter(fn ($container) => data_get($container, 'State') === 'running' && data_get($container, 'Names') !== $this->container_name)
|
|
->pluck('Names');
|
|
if ($oldContainerNames->isEmpty()) {
|
|
return;
|
|
}
|
|
if ($this->proxyLabelsChanged($oldContainerNames)) {
|
|
// The proxy drops a route that two containers define differently until the old one stops.
|
|
$this->application_deployment_queue->addLogEntry('The proxy labels changed, so the old containers stop without waiting.');
|
|
|
|
return;
|
|
}
|
|
$this->application_deployment_queue->addLogEntry("Waiting {$seconds} seconds for the proxy to send traffic to the new container.");
|
|
Sleep::for($seconds)->seconds();
|
|
}
|
|
|
|
private function proxyLabelsChanged(Collection $oldContainerNames): bool
|
|
{
|
|
$containerNames = $oldContainerNames->prepend($this->container_name)->map(fn (string $name) => escapeshellarg($name))->implode(' ');
|
|
$output = instant_remote_process(["docker inspect --format '{{json .Config.Labels}}' {$containerNames}"], $this->server, false);
|
|
|
|
return collect(preg_split('/\R/', trim((string) $output)))
|
|
->map(fn (string $labels) => collect(json_decode($labels, true) ?: [])
|
|
->filter(fn ($value, string $key) => str_starts_with($key, 'traefik.') || str_starts_with($key, 'caddy'))
|
|
->sortKeys()
|
|
->all())
|
|
->unique()
|
|
->count() > 1;
|
|
}
|
|
|
|
private function health_check()
|
|
{
|
|
try {
|
|
if ($this->server->isSwarm()) {
|
|
// Implement healthcheck for swarm
|
|
} else {
|
|
if ($this->application->isHealthcheckDisabled() && $this->application->custom_healthcheck_found === false) {
|
|
$this->markNewVersionHealthy(true);
|
|
|
|
return;
|
|
}
|
|
if ($this->application->isHealthcheckDisabled()) {
|
|
$this->application_deployment_queue->addLogEntry('Using the custom healthcheck from the Dockerfile.');
|
|
}
|
|
if ($this->container_name) {
|
|
$escapedContainerName = escapeshellarg($this->container_name);
|
|
$counter = 1;
|
|
$this->application_deployment_queue->addLogEntry('Waiting for healthcheck to pass on the new container.');
|
|
if ($this->full_healthcheck_url) {
|
|
$healthcheckLabel = $this->application->health_check_type === 'cmd' ? 'Healthcheck command' : 'Healthcheck URL';
|
|
$this->application_deployment_queue->addLogEntry("{$healthcheckLabel} (inside the container): {$this->full_healthcheck_url}");
|
|
}
|
|
$this->application_deployment_queue->addLogEntry("Waiting for the start period ({$this->application->health_check_start_period} seconds) before starting healthcheck.");
|
|
$sleeptime = 0;
|
|
while ($sleeptime < $this->application->health_check_start_period) {
|
|
Sleep::for(1)->seconds();
|
|
$sleeptime++;
|
|
}
|
|
while ($counter <= $this->application->health_check_retries) {
|
|
$this->execute_remote_command(
|
|
[
|
|
"docker inspect --format='{{json .State.Health.Status}}' {$escapedContainerName}",
|
|
'hidden' => true,
|
|
'save' => 'health_check',
|
|
'append' => false,
|
|
],
|
|
[
|
|
"docker inspect --format='{{json .State.Health.Log}}' {$escapedContainerName}",
|
|
'hidden' => true,
|
|
'save' => 'health_check_logs',
|
|
'append' => false,
|
|
],
|
|
);
|
|
$this->application_deployment_queue->addLogEntry("Attempt {$counter} of {$this->application->health_check_retries} | Healthcheck status: {$this->saved_outputs->get('health_check')}");
|
|
$health_check_logs = data_get(collect(json_decode($this->saved_outputs->get('health_check_logs')))->last(), 'Output', '(no logs)');
|
|
if (empty($health_check_logs)) {
|
|
$health_check_logs = '(no logs)';
|
|
}
|
|
$health_check_return_code = data_get(collect(json_decode($this->saved_outputs->get('health_check_logs')))->last(), 'ExitCode', '(no return code)');
|
|
if ($health_check_logs !== '(no logs)' || $health_check_return_code !== '(no return code)') {
|
|
$this->application_deployment_queue->addLogEntry("Healthcheck logs: {$health_check_logs} | Return code: {$health_check_return_code}");
|
|
}
|
|
|
|
$healthCheckStatus = $this->trimmedSavedOutput('health_check')->replace('"', '')->value();
|
|
if ($healthCheckStatus === 'healthy') {
|
|
$this->markNewVersionHealthy(true);
|
|
$this->application->update(['status' => 'running']);
|
|
$this->application_deployment_queue->addLogEntry('New container is healthy.');
|
|
break;
|
|
} elseif ($healthCheckStatus === 'unhealthy') {
|
|
$this->markNewVersionHealthy(false);
|
|
$this->application_deployment_queue->addLogEntry('New container is unhealthy.', type: 'error');
|
|
$this->query_logs();
|
|
break;
|
|
}
|
|
$counter++;
|
|
$sleeptime = 0;
|
|
while ($sleeptime < $this->application->health_check_interval) {
|
|
Sleep::for(1)->seconds();
|
|
$sleeptime++;
|
|
}
|
|
}
|
|
if ($this->trimmedSavedOutput('health_check')->replace('"', '')->value() === 'starting') {
|
|
$this->query_logs();
|
|
}
|
|
}
|
|
}
|
|
} catch (Exception $e) {
|
|
throw new DeploymentException('Health check failed ('.get_class($e).'): '.$e->getMessage(), $e->getCode(), $e);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Laravel runs failed() on a fresh copy of the job, so the health of the new version is kept outside the job instance.
|
|
*/
|
|
private function markNewVersionHealthy(bool $healthy): void
|
|
{
|
|
$key = "deployment-new-version-healthy:{$this->deployment_uuid}";
|
|
if ($healthy) {
|
|
Cache::put($key, true, now()->addDay());
|
|
} else {
|
|
Cache::forget($key);
|
|
}
|
|
}
|
|
|
|
private function newVersionIsHealthy(): bool
|
|
{
|
|
return Cache::has("deployment-new-version-healthy:{$this->deployment_uuid}");
|
|
}
|
|
|
|
private function query_logs()
|
|
{
|
|
$escapedContainerName = escapeshellarg($this->container_name);
|
|
$this->application_deployment_queue->addLogEntry('----------------------------------------');
|
|
$this->application_deployment_queue->addLogEntry('Container logs:');
|
|
$this->execute_remote_command(
|
|
[
|
|
'command' => "docker logs -n 100 {$escapedContainerName}",
|
|
'type' => 'stderr',
|
|
'ignore_errors' => true,
|
|
],
|
|
);
|
|
$this->application_deployment_queue->addLogEntry('----------------------------------------');
|
|
}
|
|
|
|
private function deploy_pull_request()
|
|
{
|
|
if ($this->application->build_pack === 'dockerimage') {
|
|
$this->deploy_dockerimage_buildpack();
|
|
|
|
return;
|
|
}
|
|
if ($this->application->build_pack === 'dockercompose') {
|
|
$this->deploy_docker_compose_buildpack();
|
|
|
|
return;
|
|
}
|
|
if ($this->use_build_server) {
|
|
$this->server = $this->build_server;
|
|
}
|
|
$this->markNewVersionHealthy(true);
|
|
$this->generate_image_names();
|
|
$this->application_deployment_queue->addLogEntry("Starting pull request (#{$this->pull_request_id}) deployment of {$this->customRepository}:{$this->application->git_branch}.");
|
|
$this->prepare_builder_image();
|
|
$this->check_git_if_build_needed();
|
|
$this->clone_repository();
|
|
$this->cleanup_git();
|
|
$this->validateDeploymentEnvironmentVariableKeys(buildsImage: true);
|
|
if ($this->application->build_pack === 'nixpacks') {
|
|
$this->generate_nixpacks_confs();
|
|
}
|
|
$this->generate_compose_file();
|
|
|
|
// Save build-time .env file BEFORE the build
|
|
$this->save_buildtime_environment_variables();
|
|
|
|
$this->generate_build_env_variables();
|
|
if ($this->application->build_pack === 'dockerfile') {
|
|
$this->add_build_env_variables_to_dockerfile();
|
|
}
|
|
$this->build_pull_request_image();
|
|
|
|
// This overwrites the build-time .env with ALL variables (build-time + runtime)
|
|
$this->save_runtime_environment_variables();
|
|
$this->push_to_docker_registry();
|
|
$this->rolling_update();
|
|
}
|
|
|
|
private function build_pull_request_image(): void
|
|
{
|
|
match ($this->application->build_pack) {
|
|
'railpack' => $this->build_railpack_image(),
|
|
'static' => $this->build_static_image(),
|
|
default => $this->build_image(),
|
|
};
|
|
}
|
|
|
|
private function create_workdir()
|
|
{
|
|
if ($this->use_build_server) {
|
|
$this->server = $this->mainServer;
|
|
$this->execute_remote_command(
|
|
[
|
|
'command' => "mkdir -p {$this->configuration_dir}",
|
|
],
|
|
);
|
|
$this->server = $this->build_server;
|
|
$this->execute_remote_command(
|
|
[
|
|
'command' => executeInDocker($this->deployment_uuid, "mkdir -p {$this->workdir}"),
|
|
],
|
|
[
|
|
'command' => "mkdir -p {$this->configuration_dir}",
|
|
],
|
|
);
|
|
} else {
|
|
$this->execute_remote_command(
|
|
[
|
|
'command' => executeInDocker($this->deployment_uuid, "mkdir -p {$this->workdir}"),
|
|
],
|
|
[
|
|
'command' => "mkdir -p {$this->configuration_dir}",
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
private function prepare_builder_image(bool $firstTry = true)
|
|
{
|
|
$this->checkForCancellation();
|
|
$helperImage = coolifyHelperImage();
|
|
$helperImage = "{$helperImage}:".getHelperVersion();
|
|
// Get user home directory
|
|
$this->serverUserHomeDir = instant_remote_process(['echo $HOME'], $this->server);
|
|
instant_remote_process(["mkdir -p {$this->serverUserHomeDir}/.docker/buildx"], $this->server);
|
|
$this->dockerConfigFileExists = instant_remote_process(["test -f {$this->serverUserHomeDir}/.docker/config.json && echo 'OK' || echo 'NOK'"], $this->server);
|
|
|
|
$env_flags = $this->generate_docker_env_flags_for_secrets();
|
|
$buildxMetadataVolume = isDev() && $this->server->isLocalhost()
|
|
? '-v coolify-buildx:/root/.docker/buildx'
|
|
: "-v {$this->serverUserHomeDir}/.docker/buildx:/root/.docker/buildx";
|
|
if ($this->use_build_server) {
|
|
if ($this->dockerConfigFileExists === 'NOK') {
|
|
throw new DeploymentException("Docker config file (~/.docker/config.json) of the SSH user ({$this->server->user}) not found on the build server ({$this->server->name}). Log in to your registry under Server → Docker Registries on the build server. On a non-root server, a login made with \"sudo docker login\" is saved for root and is not used for builds.");
|
|
}
|
|
$runCommand = "docker run -d --name {$this->deployment_uuid} {$env_flags} --rm -v {$this->serverUserHomeDir}/.docker/config.json:/root/.docker/config.json:ro {$buildxMetadataVolume} -v /var/run/docker.sock:/var/run/docker.sock {$helperImage}";
|
|
} else {
|
|
// docker compose runs in this helper and reads seccomp profile files from the custom Docker options
|
|
$seccompMounts = $this->pull_request_id === 0 ? seccompProfileMountFlags($this->application->custom_docker_run_options) : '';
|
|
if ($this->dockerConfigFileExists === 'OK') {
|
|
$safeNetwork = escapeshellarg($this->destination->network);
|
|
$runCommand = "docker run -d --network {$safeNetwork} --name {$this->deployment_uuid} {$env_flags} --rm -v {$this->serverUserHomeDir}/.docker/config.json:/root/.docker/config.json:ro {$buildxMetadataVolume} -v /var/run/docker.sock:/var/run/docker.sock {$seccompMounts} {$helperImage}";
|
|
} else {
|
|
$safeNetwork = escapeshellarg($this->destination->network);
|
|
$runCommand = "docker run -d --network {$safeNetwork} --name {$this->deployment_uuid} {$env_flags} --rm {$buildxMetadataVolume} -v /var/run/docker.sock:/var/run/docker.sock {$seccompMounts} {$helperImage}";
|
|
}
|
|
}
|
|
if ($firstTry) {
|
|
$this->application_deployment_queue->addLogEntry("Preparing container with helper image: $helperImage");
|
|
} else {
|
|
$this->application_deployment_queue->addLogEntry('Preparing container with helper image with updated envs.');
|
|
}
|
|
|
|
$this->graceful_shutdown_container($this->deployment_uuid, skipRemove: true);
|
|
$this->execute_remote_command(
|
|
[
|
|
$runCommand,
|
|
'hidden' => true,
|
|
'skip_command_log' => filled($env_flags),
|
|
],
|
|
[
|
|
'command' => executeInDocker($this->deployment_uuid, "mkdir -p {$this->basedir}"),
|
|
],
|
|
);
|
|
$this->run_pre_deployment_command();
|
|
}
|
|
|
|
private function restart_builder_container_with_actual_commit()
|
|
{
|
|
// Stop the current helper container (no need for rm -f as it was started with --rm)
|
|
$this->graceful_shutdown_container($this->deployment_uuid, skipRemove: true);
|
|
|
|
// Clear cached env_args to force regeneration with actual SOURCE_COMMIT value
|
|
$this->env_args = null;
|
|
|
|
// Restart the helper container with updated environment variables (including actual SOURCE_COMMIT)
|
|
$this->prepare_builder_image(firstTry: false);
|
|
}
|
|
|
|
/**
|
|
* Proxy labels are generated for the primary server. On an additional server whose
|
|
* network has another name, point Caddy to the network the container joins there.
|
|
*/
|
|
private function useDestinationNetworkInCaddyLabels(Collection $labels): Collection
|
|
{
|
|
$primaryNetwork = $this->application->destination->network;
|
|
$network = $this->destination->network;
|
|
if ($network === $primaryNetwork) {
|
|
return $labels;
|
|
}
|
|
|
|
return $labels->map(fn ($label) => $label === "caddy_ingress_network={$primaryNetwork}" ? "caddy_ingress_network={$network}" : $label);
|
|
}
|
|
|
|
/**
|
|
* @return int The number of deployments that Coolify created on additional servers, including ones that failed to dispatch
|
|
*/
|
|
private function deploy_to_additional_destinations(): int
|
|
{
|
|
if ($this->application->additional_networks->count() === 0) {
|
|
return 0;
|
|
}
|
|
if ($this->pull_request_id !== 0) {
|
|
return 0;
|
|
}
|
|
$destination_ids = $this->application->additional_networks->pluck('id');
|
|
if ($this->server->isSwarm()) {
|
|
$this->application_deployment_queue->addLogEntry('Additional destinations are not supported in swarm mode.');
|
|
|
|
return 0;
|
|
}
|
|
if ($destination_ids->contains($this->destination->id)) {
|
|
return 0;
|
|
}
|
|
$queued = 0;
|
|
foreach ($destination_ids as $destination_id) {
|
|
$destination = StandaloneDocker::find($destination_id);
|
|
if (! $destination) {
|
|
continue;
|
|
}
|
|
$server = $destination->server;
|
|
if ($server->team_id !== $this->mainServer->team_id) {
|
|
$this->application_deployment_queue->addLogEntry("Skipping deployment to {$server->name}. Not in the same team?!");
|
|
|
|
continue;
|
|
}
|
|
$deployment_uuid = new_public_id();
|
|
try {
|
|
// Deploy the exact commit and image tag of the main server, also for a rollback.
|
|
$result = queue_application_deployment(
|
|
deployment_uuid: $deployment_uuid,
|
|
application: $this->application,
|
|
commit: $this->commit,
|
|
is_webhook: (bool) $this->application_deployment_queue->is_webhook,
|
|
is_api: (bool) $this->application_deployment_queue->is_api,
|
|
no_questions_asked: true,
|
|
server: $server,
|
|
destination: $destination,
|
|
rollback: $this->rollback,
|
|
docker_registry_image_tag: $this->application_deployment_queue->docker_registry_image_tag,
|
|
parent_deployment_uuid: $this->deployment_uuid,
|
|
is_mcp: (bool) $this->application_deployment_queue->is_mcp,
|
|
);
|
|
} catch (Throwable $e) {
|
|
\Log::warning("Deployment {$this->deployment_uuid} could not queue the deployment to {$server->name}: {$e->getMessage()}");
|
|
$this->application_deployment_queue->addLogEntry("Deployment to {$server->name} could not be started: {$e->getMessage()}", 'stderr');
|
|
// A deployment that failed to dispatch is already failed; it counts for the combined notification.
|
|
if (ApplicationDeploymentQueue::query()->where('deployment_uuid', $deployment_uuid)->exists()) {
|
|
$queued++;
|
|
}
|
|
|
|
continue;
|
|
}
|
|
if ($result['status'] !== 'queued') {
|
|
$this->application_deployment_queue->addLogEntry("Deployment to {$server->name} was not queued: {$result['message']}", 'stderr');
|
|
|
|
continue;
|
|
}
|
|
$queued++;
|
|
$deployment_url = base_url().'/project/'.data_get($this->application, 'environment.project.uuid').'/environment/'.data_get($this->application, 'environment.uuid').'/application/'.data_get($this->application, 'uuid')."/deployment/{$deployment_uuid}";
|
|
$this->application_deployment_queue->addLogEntry("Deployment to {$server->name}. Logs: {$deployment_url}");
|
|
}
|
|
|
|
return $queued;
|
|
}
|
|
|
|
private function set_coolify_variables()
|
|
{
|
|
$this->coolify_variables = '';
|
|
|
|
// Only include SOURCE_COMMIT in build context if enabled in settings
|
|
if ($this->application->settings->include_source_commit_in_build) {
|
|
$this->coolify_variables .= 'SOURCE_COMMIT='.escapeShellValue($this->commit).' ';
|
|
}
|
|
if ($this->pull_request_id === 0) {
|
|
$fqdn = $this->application->fqdn;
|
|
} else {
|
|
$fqdn = $this->preview->fqdn;
|
|
}
|
|
if (isset($fqdn)) {
|
|
$domains = $this->validDomainsFromFqdn($fqdn);
|
|
if ($domains->isNotEmpty()) {
|
|
$url = $domains->map(fn (string $domain) => Url::fromString($domain)->withPort(null)->__toString())->implode(',');
|
|
$fqdn = $this->hostsFromFqdn($fqdn);
|
|
if ((int) $this->application->compose_parsing_version >= 3) {
|
|
$this->coolify_variables .= 'COOLIFY_URL='.escapeShellValue($url).' ';
|
|
$this->coolify_variables .= 'COOLIFY_FQDN='.escapeShellValue($fqdn).' ';
|
|
} else {
|
|
$this->coolify_variables .= 'COOLIFY_URL='.escapeShellValue($fqdn).' ';
|
|
$this->coolify_variables .= 'COOLIFY_FQDN='.escapeShellValue($url).' ';
|
|
}
|
|
}
|
|
}
|
|
if (isset($this->application->git_branch)) {
|
|
$this->coolify_variables .= 'COOLIFY_BRANCH='.escapeShellValue($this->application->git_branch).' ';
|
|
}
|
|
$this->coolify_variables .= 'COOLIFY_RESOURCE_UUID='.escapeShellValue($this->application->uuid).' ';
|
|
}
|
|
|
|
private function validDomainsFromFqdn(?string $fqdn): Collection
|
|
{
|
|
return str($fqdn ?? '')->explode(',')
|
|
->map(fn (string $domain) => trim($domain))
|
|
->filter()
|
|
->filter(fn (string $domain) => isValidDomainUrl($domain));
|
|
}
|
|
|
|
/**
|
|
* Comma-separated hosts without scheme, port, or path (the COOLIFY_FQDN value).
|
|
*/
|
|
private function hostsFromFqdn(?string $fqdn): string
|
|
{
|
|
return $this->validDomainsFromFqdn($fqdn)
|
|
->map(fn (string $domain) => Url::fromString($domain)->getHost())
|
|
->implode(',');
|
|
}
|
|
|
|
private function shellAssignmentForDockerfileArg(string $assignment): string
|
|
{
|
|
[$key, $value] = array_pad(explode('=', $assignment, 2), 2, null);
|
|
|
|
if ($value === null) {
|
|
return $assignment;
|
|
}
|
|
|
|
if (str_starts_with($value, "'") && str_ends_with($value, "'")) {
|
|
$value = substr($value, 1, -1);
|
|
$value = str_replace("'\\''", "'", $value);
|
|
}
|
|
|
|
return "{$key}={$value}";
|
|
}
|
|
|
|
private function gitLsRemoteCommand(string $lsRemoteRef, ?string $identityFile = null): string
|
|
{
|
|
$sshCommand = gitSshCommand($this->customPort, $identityFile);
|
|
|
|
$gitConfigOption = $this->application->gitHttpsCredentialConfigOption();
|
|
$gitCommand = $gitConfigOption ? "git {$gitConfigOption}" : 'git';
|
|
|
|
return 'GIT_SSH_COMMAND="'.$sshCommand.'" '.$gitCommand.' ls-remote '.escapeshellarg($this->fullRepoUrl).' '.escapeshellarg($lsRemoteRef);
|
|
}
|
|
|
|
private function update_repository_project_id(): void
|
|
{
|
|
$repositorySlug = gitRepositorySlug($this->customRepository);
|
|
$repository = githubApi($this->source, "repos/{$repositorySlug}");
|
|
$repository_project_id = data_get($repository, 'data.id');
|
|
if (isset($repository_project_id) && $this->application->repository_project_id !== $repository_project_id) {
|
|
$this->application->repository_project_id = $repository_project_id;
|
|
$this->application->save();
|
|
}
|
|
}
|
|
|
|
private function check_git_if_build_needed()
|
|
{
|
|
if (is_object($this->source) && $this->source->getMorphClass() === GithubApp::class && $this->source->is_public === false) {
|
|
$this->update_repository_project_id();
|
|
}
|
|
$this->resolve_bitbucket_pull_request_commit();
|
|
$this->generate_git_import_commands();
|
|
$local_branch = $this->branch;
|
|
if ($this->pull_request_id !== 0) {
|
|
$local_branch = "pull/{$this->pull_request_id}/head";
|
|
}
|
|
// Build an exact refspec for ls-remote so we don't match similarly named branches (e.g., changeset-release/main)
|
|
if ($this->pull_request_id === 0) {
|
|
$lsRemoteRef = "refs/heads/{$local_branch}";
|
|
} else {
|
|
if ($this->git_type === 'github' || $this->git_type === 'gitea') {
|
|
$lsRemoteRef = "refs/pull/{$this->pull_request_id}/head";
|
|
} elseif ($this->git_type === 'gitlab') {
|
|
$lsRemoteRef = "refs/merge-requests/{$this->pull_request_id}/head";
|
|
} else {
|
|
// Fallback to the original value if provider-specific ref is unknown
|
|
$lsRemoteRef = $local_branch;
|
|
}
|
|
}
|
|
$private_key = data_get($this->application, 'private_key.private_key');
|
|
if ($private_key) {
|
|
$private_key = base64_encode($private_key);
|
|
$customSshKeyLocation = "/root/.ssh/id_rsa_coolify_{$this->deployment_uuid}";
|
|
$this->execute_remote_command(
|
|
[
|
|
executeInDocker($this->deployment_uuid, 'mkdir -p /root/.ssh'),
|
|
],
|
|
[
|
|
executeInDocker($this->deployment_uuid, "echo '{$private_key}' | base64 -d | tee {$customSshKeyLocation} > /dev/null"),
|
|
'hidden' => true,
|
|
'skip_command_log' => true,
|
|
],
|
|
[
|
|
executeInDocker($this->deployment_uuid, "chmod 600 {$customSshKeyLocation}"),
|
|
],
|
|
[
|
|
executeInDocker($this->deployment_uuid, $this->gitLsRemoteCommand($lsRemoteRef, $customSshKeyLocation)),
|
|
'hidden' => true,
|
|
'save' => 'git_commit_sha',
|
|
]
|
|
);
|
|
} else {
|
|
$this->execute_remote_command(
|
|
[
|
|
executeInDocker($this->deployment_uuid, $this->gitLsRemoteCommand($lsRemoteRef)),
|
|
'hidden' => true,
|
|
'save' => 'git_commit_sha',
|
|
],
|
|
);
|
|
}
|
|
if ($this->saved_outputs->get('git_commit_sha') && ! $this->rollback && $this->shouldResolveBranchHeadCommit()) {
|
|
// Extract commit SHA from git ls-remote output, handling multi-line output (e.g., redirect warnings)
|
|
// Expected format: "commit_sha\trefs/heads/branch" possibly preceded by warning lines
|
|
// Note: Git warnings can be on the same line as the result (no newline)
|
|
$lsRemoteOutput = $this->saved_outputs->get('git_commit_sha');
|
|
|
|
// Find the part containing a tab (the actual ls-remote result)
|
|
// Handle cases where warning is on the same line as the result
|
|
if ($lsRemoteOutput->contains("\t")) {
|
|
// Get everything from the last occurrence of a valid commit SHA pattern before the tab
|
|
// A valid commit SHA is 40 hex characters
|
|
$output = $lsRemoteOutput->value();
|
|
|
|
// Extract the line with the tab (actual ls-remote result)
|
|
preg_match('/\b([0-9a-fA-F]{40})(?=\s*\t)/', $output, $matches);
|
|
|
|
if (isset($matches[1])) {
|
|
$this->commit = $matches[1];
|
|
$this->application_deployment_queue->commit = $this->commit;
|
|
$this->application_deployment_queue->save();
|
|
}
|
|
}
|
|
}
|
|
$this->reuse_commit_message();
|
|
$this->set_coolify_variables();
|
|
|
|
// Restart helper container with actual SOURCE_COMMIT value
|
|
if ($this->application->settings->use_build_secrets && $this->commit !== 'HEAD') {
|
|
$this->application_deployment_queue->addLogEntry('Restarting helper container with actual SOURCE_COMMIT value.');
|
|
$this->restart_builder_container_with_actual_commit();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Bitbucket Cloud has no pull request refs over git, so a preview deployment without a webhook
|
|
* commit (a manual redeploy) builds the commit of the latest earlier deployment of the pull request.
|
|
* Without such a commit the clone checks out the base branch, so the deployment fails instead.
|
|
*/
|
|
private function resolve_bitbucket_pull_request_commit(): void
|
|
{
|
|
if ($this->pull_request_id === 0 || $this->git_type !== 'bitbucket' || ! $this->shouldResolveBranchHeadCommit()) {
|
|
return;
|
|
}
|
|
|
|
$commit = ApplicationDeploymentQueue::query()
|
|
->where('application_id', $this->application->id)
|
|
->where('pull_request_id', $this->pull_request_id)
|
|
->whereKeyNot($this->application_deployment_queue->id)
|
|
->whereNotIn('commit', ['HEAD', ''])
|
|
->latest('id')
|
|
->value('commit');
|
|
|
|
if (blank($commit)) {
|
|
throw new DeploymentException("Coolify cannot find the commit of Bitbucket pull request #{$this->pull_request_id}, because Bitbucket Cloud has no pull request refs over git. Push a commit to the pull request to deploy it with the webhook, or deploy the preview with a commit SHA through the API.");
|
|
}
|
|
|
|
$this->commit = $commit;
|
|
$this->application_deployment_queue->commit = $commit;
|
|
$this->application_deployment_queue->save();
|
|
$this->application_deployment_queue->addLogEntry("Using commit {$commit} of the latest deployment of Bitbucket pull request #{$this->pull_request_id}.");
|
|
}
|
|
|
|
/**
|
|
* The commit message is read from the cloned repository. Deployments that skip the build or fail
|
|
* before the clone reuse the message stored by an earlier deployment of the same commit.
|
|
*/
|
|
private function reuse_commit_message(): void
|
|
{
|
|
if (filled($this->application_deployment_queue->commit_message) || $this->shouldResolveBranchHeadCommit()) {
|
|
return;
|
|
}
|
|
|
|
$commitMessage = ApplicationDeploymentQueue::query()
|
|
->where('application_id', $this->application->id)
|
|
->where('commit', $this->commit)
|
|
->whereNotNull('commit_message')
|
|
->where('commit_message', '!=', '')
|
|
->latest('id')
|
|
->value('commit_message');
|
|
|
|
if (filled($commitMessage)) {
|
|
$this->application_deployment_queue->update(['commit_message' => $commitMessage]);
|
|
}
|
|
}
|
|
|
|
private function shouldResolveBranchHeadCommit(): bool
|
|
{
|
|
$commit = trim($this->commit);
|
|
|
|
return $commit === '' || $commit === 'HEAD';
|
|
}
|
|
|
|
private function clone_repository()
|
|
{
|
|
$this->application_deployment_queue->addLogEntry("\n----------------------------------------");
|
|
$this->application_deployment_queue->addLogEntry("Importing {$this->customRepository}:{$this->application->git_branch} (commit sha {$this->commit}) to {$this->basedir}.");
|
|
if ($this->pull_request_id !== 0) {
|
|
$this->application_deployment_queue->addLogEntry("Checking out tag pull/{$this->pull_request_id}/head.");
|
|
}
|
|
$this->import_repository();
|
|
$this->create_workdir();
|
|
$this->execute_remote_command(
|
|
[
|
|
executeInDocker($this->deployment_uuid, "cd {$this->workdir} && git log -1 ".escapeshellarg($this->commit).' --pretty=%B'),
|
|
'hidden' => true,
|
|
'save' => 'commit_message',
|
|
]
|
|
);
|
|
if ($this->saved_outputs->get('commit_message')) {
|
|
$commit_message = $this->trimmedSavedOutput('commit_message');
|
|
$this->application_deployment_queue->commit_message = $commit_message->value();
|
|
ApplicationDeploymentQueue::whereCommit($this->commit)->whereApplicationId($this->application->id)->update(
|
|
['commit_message' => $commit_message->value()]
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* SSH exits with 255 when the connection drops, which can happen during a long clone. Git exits with
|
|
* 128 for its own errors, so only 255 is retried. The interrupted clone can still run in the helper
|
|
* container, so stop it and remove the partial checkout before cloning again.
|
|
*/
|
|
private function import_repository(): void
|
|
{
|
|
$maxAttempts = config('constants.ssh.max_retries');
|
|
|
|
for ($attempt = 1; ; $attempt++) {
|
|
try {
|
|
$this->execute_remote_command(...$this->gitCommandDefinitions($this->generate_git_import_commands()));
|
|
|
|
return;
|
|
} catch (DeploymentException $e) {
|
|
if ($e->getCode() !== 255 || $attempt >= $maxAttempts) {
|
|
throw $e;
|
|
}
|
|
|
|
$delay = $this->calculateRetryDelay($attempt - 1);
|
|
$this->application_deployment_queue->addLogEntry('Lost the SSH connection while importing the repository. Retrying in '.$delay.'s (attempt '.($attempt + 1)."/{$maxAttempts}).", 'stderr');
|
|
Sleep::for($delay)->seconds();
|
|
$this->execute_remote_command([
|
|
executeInDocker($this->deployment_uuid, 'pkill git; rm -rf '.escapeshellarg($this->basedir)),
|
|
'hidden' => true,
|
|
'ignore_errors' => true,
|
|
]);
|
|
}
|
|
}
|
|
}
|
|
|
|
private function generate_git_import_commands()
|
|
{
|
|
['commands' => $commands, 'branch' => $this->branch, 'fullRepoUrl' => $this->fullRepoUrl] = $this->application->generateGitImportCommands(
|
|
deployment_uuid: $this->deployment_uuid,
|
|
pull_request_id: $this->pull_request_id,
|
|
git_type: $this->git_type,
|
|
commit: $this->commit
|
|
);
|
|
|
|
return $commands;
|
|
}
|
|
|
|
private function gitCommandDefinitions(Collection|array|string $commands): array
|
|
{
|
|
if (is_string($commands)) {
|
|
return [
|
|
[
|
|
$commands,
|
|
'hidden' => true,
|
|
],
|
|
];
|
|
}
|
|
|
|
return collect($commands)
|
|
->map(function ($command): array {
|
|
if (is_string($command)) {
|
|
return [
|
|
$command,
|
|
'hidden' => true,
|
|
];
|
|
}
|
|
|
|
if (is_array($command)) {
|
|
return $command + ['hidden' => true];
|
|
}
|
|
|
|
return [
|
|
'command' => $command,
|
|
'hidden' => true,
|
|
];
|
|
})
|
|
->values()
|
|
->all();
|
|
}
|
|
|
|
private function cleanup_git()
|
|
{
|
|
$this->execute_remote_command(
|
|
[executeInDocker($this->deployment_uuid, "rm -fr {$this->basedir}/.git")],
|
|
);
|
|
}
|
|
|
|
private function generate_nixpacks_confs()
|
|
{
|
|
$nixpacks_command = $this->nixpacks_build_cmd();
|
|
$this->application_deployment_queue->addLogEntry("Generating nixpacks configuration with: $nixpacks_command");
|
|
|
|
$this->execute_remote_command(
|
|
[executeInDocker($this->deployment_uuid, $nixpacks_command), 'save' => 'nixpacks_plan', 'hidden' => true],
|
|
[executeInDocker($this->deployment_uuid, "nixpacks detect {$this->workdir}"), 'save' => 'nixpacks_type', 'hidden' => true],
|
|
);
|
|
if ($this->saved_outputs->get('nixpacks_type')) {
|
|
$this->nixpacks_type = $this->trimmedSavedOutput('nixpacks_type')->value();
|
|
if (str($this->nixpacks_type)->isEmpty()) {
|
|
throw new DeploymentException('Nixpacks failed to detect the application type. Please check the documentation of Nixpacks: https://nixpacks.com/docs/providers');
|
|
}
|
|
}
|
|
|
|
if ($this->saved_outputs->get('nixpacks_plan')) {
|
|
$this->nixpacks_plan = $this->saved_outputs->get('nixpacks_plan');
|
|
if ($this->nixpacks_plan) {
|
|
$this->application_deployment_queue->addLogEntry("Found application type: {$this->nixpacks_type}.");
|
|
$this->application_deployment_queue->addLogEntry("If you need further customization, please check the documentation of Nixpacks: https://nixpacks.com/docs/providers/{$this->nixpacks_type}");
|
|
$parsed = json_decode($this->nixpacks_plan, true);
|
|
|
|
// Do any modifications here
|
|
// We need to generate envs here because nixpacks need to know to generate a proper Dockerfile
|
|
$this->generate_env_variables();
|
|
$merged_envs = $this->withoutDottedBuildVariables(
|
|
collect(data_get($parsed, 'variables', []))->merge($this->env_args),
|
|
'Nixpacks',
|
|
);
|
|
$aptPkgs = data_get($parsed, 'phases.setup.aptPkgs', []);
|
|
if (count($aptPkgs) === 0) {
|
|
$aptPkgs = ['curl', 'wget'];
|
|
data_set($parsed, 'phases.setup.aptPkgs', ['curl', 'wget']);
|
|
} else {
|
|
if (! in_array('curl', $aptPkgs)) {
|
|
$aptPkgs[] = 'curl';
|
|
}
|
|
if (! in_array('wget', $aptPkgs)) {
|
|
$aptPkgs[] = 'wget';
|
|
}
|
|
data_set($parsed, 'phases.setup.aptPkgs', $aptPkgs);
|
|
}
|
|
data_set($parsed, 'variables', $merged_envs->toArray());
|
|
$is_laravel = data_get($parsed, 'variables.IS_LARAVEL', false);
|
|
if ($is_laravel) {
|
|
$variables = $this->laravel_finetunes();
|
|
data_set($parsed, 'variables.NIXPACKS_PHP_FALLBACK_PATH', $variables[0]->value);
|
|
data_set($parsed, 'variables.NIXPACKS_PHP_ROOT_DIR', $variables[1]->value);
|
|
}
|
|
if ($this->nixpacks_type === 'elixir') {
|
|
$this->elixir_finetunes();
|
|
}
|
|
if ($this->nixpacks_type === 'node') {
|
|
// Check if NIXPACKS_NODE_VERSION is set
|
|
$variables = data_get($parsed, 'variables', []);
|
|
if (! isset($variables['NIXPACKS_NODE_VERSION'])) {
|
|
$this->application_deployment_queue->addLogEntry('----------------------------------------');
|
|
$this->application_deployment_queue->addLogEntry('⚠️ NIXPACKS_NODE_VERSION not set. Nixpacks will use Node.js 18 by default, which is EOL.');
|
|
$this->application_deployment_queue->addLogEntry('You can override this by setting NIXPACKS_NODE_VERSION=22 in your environment variables.');
|
|
}
|
|
}
|
|
$this->nixpacks_plan = json_encode($parsed, JSON_PRETTY_PRINT);
|
|
$this->nixpacks_plan_json = collect($parsed);
|
|
|
|
if (isDev()) {
|
|
$this->application_deployment_queue->addLogEntry("Final Nixpacks plan: {$this->nixpacks_plan}", hidden: true);
|
|
} else {
|
|
$parsedForLog = $parsed;
|
|
unset($parsedForLog['variables']); // remove variables section to avoid exposing ENVs in production logs
|
|
$this->application_deployment_queue->addLogEntry('Final Nixpacks plan: '.json_encode($parsedForLog, JSON_PRETTY_PRINT), hidden: true);
|
|
}
|
|
if ($this->nixpacks_type === 'rust') {
|
|
// temporary: disable healthcheck for rust because the start phase does not have curl/wget
|
|
$this->application->health_check_enabled = false;
|
|
$this->application->save();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private function nixpacks_build_cmd()
|
|
{
|
|
$this->generate_nixpacks_env_variables();
|
|
$nixpacks_command = "nixpacks plan -f json {$this->env_nixpacks_args}";
|
|
if ($this->application->build_command) {
|
|
$nixpacks_command .= ' --build-cmd '.escapeShellValue($this->application->build_command);
|
|
}
|
|
if ($this->application->start_command) {
|
|
$nixpacks_command .= ' --start-cmd '.escapeShellValue($this->application->start_command);
|
|
}
|
|
if ($this->application->install_command) {
|
|
$nixpacks_command .= ' --install-cmd '.escapeShellValue($this->application->install_command);
|
|
}
|
|
$nixpacks_command .= " {$this->workdir}";
|
|
|
|
return $nixpacks_command;
|
|
}
|
|
|
|
private function generate_nixpacks_env_variables()
|
|
{
|
|
$this->env_nixpacks_args = collect([]);
|
|
if ($this->pull_request_id === 0) {
|
|
foreach ($this->application->nixpacks_environment_variables as $env) {
|
|
$resolvedValue = $env->getResolvedValueWithServer($this->mainServer);
|
|
if (! is_null($resolvedValue) && $resolvedValue !== '') {
|
|
$value = ($env->is_literal || $env->is_multiline) ? trim($resolvedValue, "'") : $resolvedValue;
|
|
$this->env_nixpacks_args->push('--env '.escapeShellValue("{$env->key}={$value}"));
|
|
}
|
|
}
|
|
} else {
|
|
foreach ($this->application->nixpacks_environment_variables_preview()->whereNotIn('id', $this->previewVariableIdsWithProductionValues())->get() as $env) {
|
|
$resolvedValue = $env->getResolvedValueWithServer($this->mainServer);
|
|
if (! is_null($resolvedValue) && $resolvedValue !== '') {
|
|
$value = ($env->is_literal || $env->is_multiline) ? trim($resolvedValue, "'") : $resolvedValue;
|
|
$this->env_nixpacks_args->push('--env '.escapeShellValue("{$env->key}={$value}"));
|
|
}
|
|
}
|
|
}
|
|
|
|
// Add COOLIFY_* environment variables to Nixpacks build context
|
|
$coolify_envs = $this->generate_coolify_env_variables(forBuildTime: true);
|
|
$coolify_envs->each(function ($value, $key) {
|
|
// Only add environment variables with non-null and non-empty values
|
|
if (! is_null($value) && $value !== '') {
|
|
$this->env_nixpacks_args->push('--env '.escapeShellValue("{$key}={$value}"));
|
|
}
|
|
});
|
|
|
|
$this->env_nixpacks_args = $this->env_nixpacks_args->implode(' ');
|
|
}
|
|
|
|
private function is_reserved_docker_client_env_key(?string $key): bool
|
|
{
|
|
if (blank($key)) {
|
|
return false;
|
|
}
|
|
|
|
return in_array(strtoupper($key), self::DOCKER_CLIENT_ENV_KEYS, true);
|
|
}
|
|
|
|
private function without_reserved_docker_client_variables(Collection $variables): Collection
|
|
{
|
|
return $variables->reject(fn ($value, $key) => $this->is_reserved_docker_client_env_key((string) $key));
|
|
}
|
|
|
|
private function generate_railpack_env_variables(): Collection
|
|
{
|
|
$variables = $this->railpack_build_variables();
|
|
|
|
$this->env_railpack_args = $variables
|
|
->map(function ($value, $key) {
|
|
return '--env '.escapeShellValue("{$key}={$value}");
|
|
})
|
|
->implode(' ');
|
|
|
|
return $variables;
|
|
}
|
|
|
|
private function normalize_resolved_build_variable_value(EnvironmentVariable $environmentVariable): ?string
|
|
{
|
|
if ($this->uses_remote_secret_references($environmentVariable)) {
|
|
$resolved = $this->resolve_environment_variable_raw($environmentVariable);
|
|
|
|
return $resolved === '' ? null : $resolved;
|
|
}
|
|
|
|
$resolvedValue = $environmentVariable->getResolvedValueWithServer($this->mainServer);
|
|
if (is_null($resolvedValue) || $resolvedValue === '') {
|
|
return null;
|
|
}
|
|
|
|
if ($environmentVariable->is_literal || $environmentVariable->is_multiline) {
|
|
return trim($resolvedValue, "'");
|
|
}
|
|
|
|
return $resolvedValue;
|
|
}
|
|
|
|
/**
|
|
* All buildtime variables that must reach the Railpack build.
|
|
*
|
|
* Railpack's BuildKit frontend treats every `--env` passed to `railpack prepare`
|
|
* as a build secret entry in the generated plan, then pairs it with `--secret id=,env=`
|
|
* on `docker buildx build`. Because Railpack's schema disallows top-level `variables`
|
|
* (unlike Nixpacks, which bakes variables into the plan), this `--env` → `--secret`
|
|
* channel is the only way user-defined buildtime variables become available to
|
|
* commands declared with `useSecrets: true`.
|
|
*/
|
|
private function railpack_build_variables(): Collection
|
|
{
|
|
$genericBuildVariables = $this->pull_request_id === 0
|
|
? $this->application->environment_variables()->withoutBuildpackControlVariables()->where('is_buildtime', true)->get()
|
|
: $this->application->environment_variables_preview()->whereNotIn('id', $this->previewVariableIdsWithProductionValues())->withoutBuildpackControlVariables()->where('is_buildtime', true)->get();
|
|
|
|
$railpackVariables = $this->pull_request_id === 0
|
|
? $this->application->railpack_environment_variables()->get()
|
|
: $this->application->railpack_environment_variables_preview()->whereNotIn('id', $this->previewVariableIdsWithProductionValues())->get();
|
|
|
|
$variables = $this->withoutLegacyEnvironmentVariableKeys($genericBuildVariables->merge($railpackVariables))
|
|
->mapWithKeys(function (EnvironmentVariable $environmentVariable) {
|
|
$value = $this->normalize_resolved_build_variable_value($environmentVariable);
|
|
if (is_null($value) || $value === '') {
|
|
return [];
|
|
}
|
|
|
|
return [$environmentVariable->key => $value];
|
|
});
|
|
$variables = $this->withoutDottedBuildVariables($variables, 'Railpack');
|
|
|
|
if ($this->application->install_command) {
|
|
$variables->put('RAILPACK_INSTALL_CMD', $this->application->install_command);
|
|
}
|
|
|
|
$variables = $this->merge_railpack_deploy_apt_packages($variables);
|
|
|
|
// Mirror Nixpacks behavior: expose COOLIFY_* and SOURCE_COMMIT to the build so apps
|
|
// (e.g. SPAs baking the public URL) can read them via /run/secrets/<KEY>.
|
|
foreach ($this->generate_coolify_env_variables(forBuildTime: true) as $key => $value) {
|
|
if (! is_null($value) && $value !== '') {
|
|
$variables->put($key, $value);
|
|
}
|
|
}
|
|
|
|
return $variables;
|
|
}
|
|
|
|
/**
|
|
* Without the `...` entry, the list replaces the packages that Railpack generates
|
|
* (for example libatomic1, which Node.js 25+ needs at runtime).
|
|
*/
|
|
private function merge_railpack_deploy_apt_packages(Collection $variables): Collection
|
|
{
|
|
$packages = collect(preg_split('/\s+/', trim((string) $variables->get('RAILPACK_DEPLOY_APT_PACKAGES', ''))) ?: [])
|
|
->filter()
|
|
->values();
|
|
|
|
if (! $packages->contains('...')) {
|
|
$packages->prepend('...');
|
|
}
|
|
|
|
foreach (['curl', 'wget'] as $package) {
|
|
if (! $packages->contains($package)) {
|
|
$packages->push($package);
|
|
}
|
|
}
|
|
|
|
$variables->put('RAILPACK_DEPLOY_APT_PACKAGES', $packages->implode(' '));
|
|
|
|
return $variables;
|
|
}
|
|
|
|
private function railpack_build_environment_prefix(Collection $variables): string
|
|
{
|
|
if ($variables->isEmpty()) {
|
|
return '';
|
|
}
|
|
|
|
return 'env '.$variables
|
|
->map(function ($value, $key) {
|
|
$key = $this->validatedBuildtimeEnvironmentVariableKey((string) $key, 'the Railpack environment');
|
|
|
|
return escapeShellValue("{$key}={$value}");
|
|
})
|
|
->implode(' ').' ';
|
|
}
|
|
|
|
private function railpack_build_secret_flags(Collection $variables): string
|
|
{
|
|
if ($variables->isEmpty()) {
|
|
return '';
|
|
}
|
|
|
|
return ' '.$variables
|
|
->map(function ($value, $key) {
|
|
$key = $this->validatedBuildtimeEnvironmentVariableKey((string) $key, 'the Railpack environment');
|
|
|
|
return '--secret '.escapeShellValue("id={$key},env={$key}");
|
|
})
|
|
->implode(' ');
|
|
}
|
|
|
|
private function railpack_build_command(string $imageName, Collection $variables): string
|
|
{
|
|
$variables = $this->without_reserved_docker_client_variables($variables);
|
|
|
|
$cacheArgs = '';
|
|
if ($this->force_rebuild) {
|
|
$cacheArgs = '--no-cache';
|
|
} else {
|
|
$cacheArgs = "--build-arg cache-key='{$this->application->uuid}'";
|
|
}
|
|
|
|
if ($variables->isNotEmpty()) {
|
|
$cacheArgs .= ' --build-arg secrets-hash='.$this->generate_secrets_hash($variables);
|
|
}
|
|
|
|
// Build-time variables reach the build through the sourced build-time .env file
|
|
// (written by save_buildtime_environment_variables), which interpolates shell-style
|
|
// references such as BETTER_AUTH_URL=$COOLIFY_URL. Passing them inline via `env`
|
|
// would forward the literal `$COOLIFY_URL` because each value is single-quoted and
|
|
// `env` does not interpolate its own assignments. Only buildpack control variables
|
|
// (NIXPACKS_/RAILPACK_) — which are excluded from the build-time .env file and never
|
|
// need interpolation — are still passed inline.
|
|
$controlVariables = $variables->filter(
|
|
fn ($value, $key) => str($key)->startsWith(EnvironmentVariable::BUILDPACK_CONTROL_VARIABLE_PREFIXES)
|
|
);
|
|
|
|
$environmentPrefix = $this->railpack_build_environment_prefix($controlVariables);
|
|
$secretFlags = $this->railpack_build_secret_flags($variables);
|
|
// The helper image sets RAILPACK_VERSION, so the frontend always matches the Railpack CLI in the helper.
|
|
// Helper images without it fall back to the version of the Railpack CLI itself.
|
|
$frontendImage = 'ghcr.io/railwayapp/railpack-frontend:v${RAILPACK_VERSION:-$(railpack --version | cut -d " " -f 3)}';
|
|
|
|
$buildxBuildCommand = "{$environmentPrefix}DOCKER_CONFIG=/root/.docker docker buildx build --builder coolify-railpack"
|
|
." {$this->addHosts} --network host"
|
|
." --build-arg BUILDKIT_SYNTAX=\"{$frontendImage}\""
|
|
." {$cacheArgs}"
|
|
."{$secretFlags}"
|
|
.' -f /artifacts/railpack-plan.json'
|
|
.' --progress plain'
|
|
.' --load'
|
|
." -t {$imageName}"
|
|
." {$this->workdir}";
|
|
|
|
return 'DOCKER_CONFIG=/root/.docker docker buildx create --name coolify-railpack --driver docker-container 2>/dev/null || true'
|
|
."\n".railpackBuilderLockedScript(railpackBuilderBootstrapScript().'; ('.$this->wrap_build_command_with_env_export($buildxBuildCommand).')');
|
|
}
|
|
|
|
private function decode_railpack_config(string $config, string $source): array
|
|
{
|
|
try {
|
|
$decoded = json_decode($config, true, 512, JSON_THROW_ON_ERROR);
|
|
} catch (JsonException $exception) {
|
|
throw new DeploymentException("Invalid {$source}: {$exception->getMessage()}", $exception->getCode(), $exception);
|
|
}
|
|
|
|
if (! is_array($decoded)) {
|
|
throw new DeploymentException("Invalid {$source}: expected a JSON object.");
|
|
}
|
|
|
|
return $decoded;
|
|
}
|
|
|
|
private function is_assoc_array(array $value): bool
|
|
{
|
|
if ($value === []) {
|
|
return false;
|
|
}
|
|
|
|
return array_keys($value) !== range(0, count($value) - 1);
|
|
}
|
|
|
|
private function merge_railpack_config(array $base, array $overrides): array
|
|
{
|
|
foreach ($overrides as $key => $value) {
|
|
if (
|
|
array_key_exists($key, $base)
|
|
&& is_array($base[$key])
|
|
&& is_array($value)
|
|
&& $this->is_assoc_array($base[$key])
|
|
&& $this->is_assoc_array($value)
|
|
) {
|
|
$base[$key] = $this->merge_railpack_config($base[$key], $value);
|
|
} else {
|
|
$base[$key] = $value;
|
|
}
|
|
}
|
|
|
|
return $base;
|
|
}
|
|
|
|
private function railpack_config_overrides(): array
|
|
{
|
|
return [];
|
|
}
|
|
|
|
private function generated_railpack_config_relative_path(): string
|
|
{
|
|
return self::RAILPACK_GENERATED_CONFIG_PATH;
|
|
}
|
|
|
|
private function generated_railpack_config_absolute_path(): string
|
|
{
|
|
return "{$this->workdir}/".self::RAILPACK_GENERATED_CONFIG_PATH;
|
|
}
|
|
|
|
private function generate_railpack_config_file(): ?string
|
|
{
|
|
$repositoryConfig = [];
|
|
$this->execute_remote_command([
|
|
executeInDocker($this->deployment_uuid, "test -f {$this->workdir}/".self::RAILPACK_REPOSITORY_CONFIG_PATH." && echo 'exists' || echo 'missing'"),
|
|
'hidden' => true,
|
|
'save' => 'railpack_config_exists',
|
|
]);
|
|
|
|
if (str($this->saved_outputs->get('railpack_config_exists'))->trim()->toString() === 'exists') {
|
|
$this->execute_remote_command([
|
|
executeInDocker($this->deployment_uuid, "cat {$this->workdir}/".self::RAILPACK_REPOSITORY_CONFIG_PATH),
|
|
'hidden' => true,
|
|
'save' => 'railpack_repository_config',
|
|
]);
|
|
|
|
$repositoryConfig = $this->decode_railpack_config(
|
|
$this->saved_outputs->get('railpack_repository_config', ''),
|
|
'repository railpack.json'
|
|
);
|
|
}
|
|
|
|
$overrides = $this->railpack_config_overrides();
|
|
if ($repositoryConfig === [] && $overrides === []) {
|
|
return null;
|
|
}
|
|
|
|
$mergedConfig = $this->merge_railpack_config($repositoryConfig, $overrides);
|
|
if (! array_key_exists('$schema', $mergedConfig)) {
|
|
$mergedConfig['$schema'] = 'https://schema.railpack.com';
|
|
}
|
|
|
|
try {
|
|
$encodedConfig = json_encode($mergedConfig, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR);
|
|
} catch (JsonException $exception) {
|
|
throw new DeploymentException("Failed to encode generated Railpack config: {$exception->getMessage()}", $exception->getCode(), $exception);
|
|
}
|
|
|
|
$configPath = $this->generated_railpack_config_absolute_path();
|
|
$encodedConfig = base64_encode($encodedConfig);
|
|
|
|
$this->execute_remote_command(
|
|
[
|
|
executeInDocker($this->deployment_uuid, "mkdir -p {$this->workdir}/.coolify"),
|
|
'hidden' => true,
|
|
],
|
|
[
|
|
executeInDocker($this->deployment_uuid, "echo '{$encodedConfig}' | base64 -d | tee {$configPath} > /dev/null"),
|
|
'hidden' => true,
|
|
]
|
|
);
|
|
|
|
if (isDev()) {
|
|
$this->application_deployment_queue->addLogEntry('Generated Railpack config: '.json_encode($mergedConfig, JSON_PRETTY_PRINT), hidden: true);
|
|
}
|
|
|
|
return $this->generated_railpack_config_relative_path();
|
|
}
|
|
|
|
private function railpack_prepare_command(?string $configFilePath = null): string
|
|
{
|
|
$prepare_command = 'railpack prepare';
|
|
|
|
if ($this->application->build_command) {
|
|
$prepare_command .= ' --build-cmd '.escapeShellValue($this->application->build_command);
|
|
}
|
|
|
|
if ($this->application->start_command) {
|
|
$prepare_command .= ' --start-cmd '.escapeShellValue($this->application->start_command);
|
|
}
|
|
|
|
if ($this->env_railpack_args) {
|
|
$prepare_command .= " {$this->env_railpack_args}";
|
|
}
|
|
|
|
if ($configFilePath) {
|
|
$prepare_command .= ' --config-file '.escapeShellValue($configFilePath);
|
|
}
|
|
|
|
$prepare_command .= " --plan-out /artifacts/railpack-plan.json {$this->workdir}";
|
|
|
|
return $prepare_command;
|
|
}
|
|
|
|
private function ensure_docker_buildx_available_for_railpack(): void
|
|
{
|
|
if ($this->dockerBuildxAvailable) {
|
|
return;
|
|
}
|
|
|
|
throw new DeploymentException('Railpack deployments require the Docker buildx CLI plugin on the build server. Install or enable docker buildx and retry the deployment.');
|
|
}
|
|
|
|
private function ensure_helper_docker_buildx_available_for_railpack(): void
|
|
{
|
|
$this->execute_remote_command([
|
|
executeInDocker($this->deployment_uuid, 'DOCKER_CONFIG=/root/.docker docker buildx version >/dev/null 2>&1 && echo available || echo not-available'),
|
|
'hidden' => true,
|
|
'save' => 'railpack_helper_buildx_available',
|
|
]);
|
|
|
|
if (trim((string) $this->saved_outputs->get('railpack_helper_buildx_available')) === 'available') {
|
|
return;
|
|
}
|
|
|
|
throw new DeploymentException('Railpack deployments require the Docker buildx CLI plugin inside the Coolify helper container. The helper could not find buildx at /root/.docker/cli-plugins/docker-buildx. Pull the latest helper image and retry the deployment.');
|
|
}
|
|
|
|
private function build_railpack_image(): void
|
|
{
|
|
$this->ensure_docker_buildx_available_for_railpack();
|
|
$this->ensure_helper_docker_buildx_available_for_railpack();
|
|
|
|
$railpackVariables = $this->generate_railpack_env_variables();
|
|
$railpackConfigPath = $this->generate_railpack_config_file();
|
|
|
|
// Step 1: Generate build plan with railpack prepare
|
|
$prepare_command = $this->railpack_prepare_command($railpackConfigPath);
|
|
|
|
$this->application_deployment_queue->addLogEntry('Generating Railpack build plan.');
|
|
$this->execute_remote_command(
|
|
[executeInDocker($this->deployment_uuid, $prepare_command), 'hidden' => true, 'skip_command_log' => true],
|
|
[
|
|
executeInDocker($this->deployment_uuid, 'cat /artifacts/railpack-plan.json'),
|
|
'hidden' => true,
|
|
'save' => 'railpack_plan',
|
|
],
|
|
);
|
|
|
|
$railpackPlanRaw = $this->saved_outputs->get('railpack_plan');
|
|
if (! empty($railpackPlanRaw)) {
|
|
if (isDev()) {
|
|
$this->application_deployment_queue->addLogEntry("Final Railpack plan: {$railpackPlanRaw}", hidden: true);
|
|
} else {
|
|
$parsedPlan = json_decode($railpackPlanRaw, true);
|
|
if (is_array($parsedPlan)) {
|
|
// Strip secrets array to avoid logging variable names in production.
|
|
unset($parsedPlan['secrets']);
|
|
$this->application_deployment_queue->addLogEntry('Final Railpack plan: '.json_encode($parsedPlan, JSON_PRETTY_PRINT), hidden: true);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Step 2: Build image using docker buildx with railpack frontend.
|
|
// Railpack's frontend requires full BuildKit (mergeop), so we use a docker-container driver builder.
|
|
$this->application_deployment_queue->addLogEntry('Building docker image with Railpack.');
|
|
$this->application_deployment_queue->addLogEntry('To check the current progress, click on Show Debug Logs.');
|
|
|
|
$image_name = $this->application->settings->is_static
|
|
? $this->build_image_name
|
|
: $this->production_image_name;
|
|
|
|
if ($this->application->settings->is_static && $this->application->static_image) {
|
|
$this->pull_latest_image($this->staticImage());
|
|
}
|
|
|
|
$build_command = $this->railpack_build_command($image_name, $railpackVariables);
|
|
|
|
$base64_build_command = base64_encode($build_command);
|
|
$this->execute_remote_command(
|
|
[
|
|
executeInDocker($this->deployment_uuid, "echo '{$base64_build_command}' | base64 -d | tee ".self::BUILD_SCRIPT_PATH.' > /dev/null'),
|
|
'hidden' => true,
|
|
],
|
|
[
|
|
executeInDocker($this->deployment_uuid, 'cat '.self::BUILD_SCRIPT_PATH),
|
|
'hidden' => true,
|
|
],
|
|
[
|
|
executeInDocker($this->deployment_uuid, 'bash '.self::BUILD_SCRIPT_PATH),
|
|
'hidden' => true,
|
|
]
|
|
);
|
|
|
|
// Step 3: If static, copy built assets into nginx image
|
|
if ($this->application->settings->is_static) {
|
|
$this->build_railpack_static_image();
|
|
}
|
|
}
|
|
|
|
private function build_railpack_static_image(): void
|
|
{
|
|
$publishDir = trim($this->application->publish_directory, '/');
|
|
$publishDir = $publishDir ? "/{$publishDir}" : '';
|
|
$dockerfile = base64_encode("FROM {$this->staticImage()}
|
|
WORKDIR /usr/share/nginx/html/
|
|
LABEL coolify.deploymentId={$this->deployment_uuid}
|
|
COPY --from={$this->build_image_name} /app{$publishDir} .
|
|
COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
|
|
|
|
if (str($this->application->custom_nginx_configuration)->isNotEmpty()) {
|
|
$nginx_config = base64_encode($this->application->custom_nginx_configuration);
|
|
} else {
|
|
$nginx_config = $this->application->settings->is_spa
|
|
? base64_encode(defaultNginxConfiguration('spa'))
|
|
: base64_encode(defaultNginxConfiguration());
|
|
}
|
|
|
|
$static_build = $this->dockerBuildkitSupported
|
|
? "DOCKER_BUILDKIT=1 docker build {$this->addHosts} --network host -f {$this->workdir}/Dockerfile --progress plain -t {$this->production_image_name} {$this->workdir}"
|
|
: "docker build {$this->addHosts} --network host -f {$this->workdir}/Dockerfile -t {$this->production_image_name} {$this->workdir}";
|
|
|
|
$base64_static_build = base64_encode($static_build);
|
|
$this->execute_remote_command(
|
|
[executeInDocker($this->deployment_uuid, "echo '{$dockerfile}' | base64 -d | tee {$this->workdir}/Dockerfile > /dev/null")],
|
|
[executeInDocker($this->deployment_uuid, "echo '{$nginx_config}' | base64 -d | tee {$this->workdir}/nginx.conf > /dev/null")],
|
|
[executeInDocker($this->deployment_uuid, "echo '{$base64_static_build}' | base64 -d | tee ".self::BUILD_SCRIPT_PATH.' > /dev/null'), 'hidden' => true],
|
|
[executeInDocker($this->deployment_uuid, 'cat '.self::BUILD_SCRIPT_PATH), 'hidden' => true],
|
|
[executeInDocker($this->deployment_uuid, 'bash '.self::BUILD_SCRIPT_PATH), 'hidden' => true],
|
|
);
|
|
}
|
|
|
|
protected function generate_coolify_env_variables(bool $forBuildTime = false): Collection
|
|
{
|
|
$coolify_envs = collect([]);
|
|
$local_branch = $this->branch;
|
|
if ($this->pull_request_id !== 0) {
|
|
// Only add SOURCE_COMMIT for runtime OR when explicitly enabled for build-time
|
|
// SOURCE_COMMIT changes with each commit and breaks Docker cache if included in build
|
|
if (! $forBuildTime || $this->application->settings->include_source_commit_in_build) {
|
|
if ($this->deployedPreviewVariables()->where('key', 'SOURCE_COMMIT')->isEmpty()) {
|
|
if (! is_null($this->commit)) {
|
|
$coolify_envs->put('SOURCE_COMMIT', $this->commit);
|
|
} else {
|
|
$coolify_envs->put('SOURCE_COMMIT', 'unknown');
|
|
}
|
|
}
|
|
}
|
|
if ($this->deployedPreviewVariables()->where('key', 'COOLIFY_FQDN')->isEmpty()) {
|
|
if ((int) $this->application->compose_parsing_version >= 3) {
|
|
$coolify_envs->put('COOLIFY_URL', $this->preview->fqdn);
|
|
} else {
|
|
$coolify_envs->put('COOLIFY_FQDN', $this->preview->fqdn);
|
|
}
|
|
}
|
|
if ($this->deployedPreviewVariables()->where('key', 'COOLIFY_URL')->isEmpty()) {
|
|
$url = $this->hostsFromFqdn($this->preview->fqdn);
|
|
if ((int) $this->application->compose_parsing_version >= 3) {
|
|
$coolify_envs->put('COOLIFY_FQDN', $url);
|
|
} else {
|
|
$coolify_envs->put('COOLIFY_URL', $url);
|
|
}
|
|
}
|
|
if ($this->application->build_pack !== 'dockercompose' || $this->application->compose_parsing_version === '1' || $this->application->compose_parsing_version === '2') {
|
|
if ($this->deployedPreviewVariables()->where('key', 'COOLIFY_BRANCH')->isEmpty()) {
|
|
$coolify_envs->put('COOLIFY_BRANCH', $local_branch);
|
|
}
|
|
if ($this->deployedPreviewVariables()->where('key', 'COOLIFY_RESOURCE_UUID')->isEmpty()) {
|
|
$coolify_envs->put('COOLIFY_RESOURCE_UUID', $this->application->uuid);
|
|
}
|
|
// Only add COOLIFY_CONTAINER_NAME for runtime (not build-time) - it changes every deployment and breaks Docker cache
|
|
if (! $forBuildTime) {
|
|
if ($this->deployedPreviewVariables()->where('key', 'COOLIFY_CONTAINER_NAME')->isEmpty()) {
|
|
$coolify_envs->put('COOLIFY_CONTAINER_NAME', $this->container_name);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Runtime only, so a project or server change does not break the Docker build cache
|
|
if (! $forBuildTime) {
|
|
$coolify_envs = $coolify_envs->merge(coolifyDefaultEnvironmentVariables($this->application, $this->deployedPreviewVariables()->pluck('key'), $this->mainServer));
|
|
}
|
|
|
|
} else {
|
|
// Only add SOURCE_COMMIT for runtime OR when explicitly enabled for build-time
|
|
// SOURCE_COMMIT changes with each commit and breaks Docker cache if included in build
|
|
if (! $forBuildTime || $this->application->settings->include_source_commit_in_build) {
|
|
if ($this->application->environment_variables->where('key', 'SOURCE_COMMIT')->isEmpty()) {
|
|
if (! is_null($this->commit)) {
|
|
$coolify_envs->put('SOURCE_COMMIT', $this->commit);
|
|
} else {
|
|
$coolify_envs->put('SOURCE_COMMIT', 'unknown');
|
|
}
|
|
}
|
|
}
|
|
if ($this->application->environment_variables->where('key', 'COOLIFY_FQDN')->isEmpty()) {
|
|
if ((int) $this->application->compose_parsing_version >= 3) {
|
|
$coolify_envs->put('COOLIFY_URL', $this->application->fqdn);
|
|
} else {
|
|
$coolify_envs->put('COOLIFY_FQDN', $this->application->fqdn);
|
|
}
|
|
}
|
|
if ($this->application->environment_variables->where('key', 'COOLIFY_URL')->isEmpty()) {
|
|
$url = $this->hostsFromFqdn($this->application->fqdn);
|
|
if ((int) $this->application->compose_parsing_version >= 3) {
|
|
$coolify_envs->put('COOLIFY_FQDN', $url);
|
|
} else {
|
|
$coolify_envs->put('COOLIFY_URL', $url);
|
|
}
|
|
}
|
|
if ($this->application->build_pack !== 'dockercompose' || $this->application->compose_parsing_version === '1' || $this->application->compose_parsing_version === '2') {
|
|
if ($this->application->environment_variables->where('key', 'COOLIFY_BRANCH')->isEmpty()) {
|
|
$coolify_envs->put('COOLIFY_BRANCH', $local_branch);
|
|
}
|
|
if ($this->application->environment_variables->where('key', 'COOLIFY_RESOURCE_UUID')->isEmpty()) {
|
|
$coolify_envs->put('COOLIFY_RESOURCE_UUID', $this->application->uuid);
|
|
}
|
|
// Only add COOLIFY_CONTAINER_NAME for runtime (not build-time) - it changes every deployment and breaks Docker cache
|
|
if (! $forBuildTime) {
|
|
if ($this->application->environment_variables->where('key', 'COOLIFY_CONTAINER_NAME')->isEmpty()) {
|
|
$coolify_envs->put('COOLIFY_CONTAINER_NAME', $this->container_name);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Runtime only, so a project or server change does not break the Docker build cache
|
|
if (! $forBuildTime) {
|
|
$coolify_envs = $coolify_envs->merge(coolifyDefaultEnvironmentVariables($this->application, $this->application->environment_variables->pluck('key'), $this->mainServer));
|
|
}
|
|
|
|
}
|
|
|
|
return $coolify_envs;
|
|
}
|
|
|
|
private function generate_env_variables()
|
|
{
|
|
$this->env_args = collect([]);
|
|
|
|
// Only include SOURCE_COMMIT in build args if enabled in settings
|
|
if ($this->application->settings->include_source_commit_in_build) {
|
|
$this->env_args->put('SOURCE_COMMIT', $this->commit);
|
|
}
|
|
|
|
$coolify_envs = $this->generate_coolify_env_variables(forBuildTime: true);
|
|
$coolify_envs->each(function ($value, $key) {
|
|
if (! is_null($value) && $value !== '') {
|
|
$this->env_args->put($key, $value);
|
|
}
|
|
});
|
|
|
|
// For build process, include only environment variables where is_buildtime = true
|
|
if ($this->pull_request_id === 0) {
|
|
$envs = $this->application->environment_variables()
|
|
->withoutBuildpackControlVariables()
|
|
->where('is_buildtime', true)
|
|
->get();
|
|
$envs = $this->withoutLegacyEnvironmentVariableKeys($envs);
|
|
|
|
if ($this->build_pack === 'dockercompose') {
|
|
$envs = $envs->reject(fn (EnvironmentVariable $env) => $this->isGeneratedDockerComposeEnvironmentVariable($env));
|
|
}
|
|
|
|
foreach ($envs as $env) {
|
|
if ($this->shouldOmitBlankComposeEnvironmentVariable($env)) {
|
|
continue;
|
|
}
|
|
|
|
$resolvedValue = $this->uses_remote_secret_references($env)
|
|
? $this->resolve_environment_variable_raw($env)
|
|
: $env->getResolvedValueWithServer($this->mainServer);
|
|
if (! is_null($resolvedValue)) {
|
|
$this->env_args->put($env->key, $resolvedValue);
|
|
}
|
|
}
|
|
} else {
|
|
$envs = $this->application->environment_variables_preview()
|
|
->whereNotIn('id', $this->previewVariableIdsWithProductionValues())
|
|
->withoutBuildpackControlVariables()
|
|
->where('is_buildtime', true)
|
|
->get();
|
|
$envs = $this->withoutLegacyEnvironmentVariableKeys($envs);
|
|
|
|
if ($this->build_pack === 'dockercompose') {
|
|
$envs = $envs->reject(fn (EnvironmentVariable $env) => $this->isGeneratedDockerComposeEnvironmentVariable($env));
|
|
}
|
|
|
|
foreach ($envs as $env) {
|
|
if ($this->shouldOmitBlankComposeEnvironmentVariable($env)) {
|
|
continue;
|
|
}
|
|
|
|
$resolvedValue = $this->uses_remote_secret_references($env)
|
|
? $this->resolve_environment_variable_raw($env)
|
|
: $env->getResolvedValueWithServer($this->mainServer);
|
|
if (! is_null($resolvedValue)) {
|
|
$this->env_args->put($env->key, $resolvedValue);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private function generate_compose_file()
|
|
{
|
|
$this->checkForCancellation();
|
|
$this->create_workdir();
|
|
$ports = $this->application->main_port();
|
|
$persistent_storages = $this->generate_local_persistent_volumes();
|
|
$persistent_file_volumes = $this->application->fileStorages()->get();
|
|
$volume_names = $this->generate_local_persistent_volumes_only_volume_names();
|
|
if (data_get($this->application, 'custom_labels')) {
|
|
$this->application->parseContainerLabels();
|
|
$labels = collect(preg_split("/\r\n|\n|\r/", base64_decode($this->application->custom_labels)));
|
|
$labels = $labels->filter(function ($value, $key) {
|
|
return ! Str::startsWith($value, 'coolify.');
|
|
});
|
|
$this->application->custom_labels = base64_encode($labels->implode("\n"));
|
|
$this->application->save();
|
|
} else {
|
|
if ($this->application->settings->is_container_label_readonly_enabled) {
|
|
$labels = collect(generateLabelsApplication($this->application, $this->preview));
|
|
}
|
|
}
|
|
if ($this->pull_request_id !== 0) {
|
|
$labels = collect(generateLabelsApplication($this->application, $this->preview));
|
|
}
|
|
$labels = $this->useDestinationNetworkInCaddyLabels($labels);
|
|
$labels = traefikHostRulesWithoutIpv6Brackets($labels);
|
|
if ($this->application->settings->is_container_label_escape_enabled) {
|
|
$labels = $labels->map(function ($value, $key) {
|
|
return escapeDollarSign($value);
|
|
});
|
|
}
|
|
$labels = $labels->merge(defaultLabels($this->application->uuid, $this->application->uuid, $this->application->project()->name, $this->application->name, $this->application->environment->name, $this->pull_request_id))->toArray();
|
|
|
|
// Check for custom HEALTHCHECK
|
|
if ($this->application->build_pack === 'dockerfile' || $this->application->dockerfile) {
|
|
$this->execute_remote_command([
|
|
executeInDocker($this->deployment_uuid, "cat {$this->workdir}{$this->dockerfile_location}"),
|
|
'hidden' => true,
|
|
'save' => 'dockerfile_from_repo',
|
|
'ignore_errors' => true,
|
|
]);
|
|
$this->application->parseHealthcheckFromDockerfile($this->saved_outputs->get('dockerfile_from_repo'));
|
|
}
|
|
$custom_network_aliases = [];
|
|
if (! empty($this->application->custom_network_aliases_array)) {
|
|
$custom_network_aliases = $this->application->custom_network_aliases_array;
|
|
}
|
|
$docker_compose = [
|
|
'services' => [
|
|
$this->container_name => [
|
|
'image' => $this->production_image_name,
|
|
'container_name' => $this->container_name,
|
|
'restart' => RESTART_MODE,
|
|
...(! empty($ports) ? ['expose' => $ports] : []),
|
|
'networks' => [
|
|
$this->destination->network => [
|
|
'aliases' => array_merge(
|
|
[$this->container_name],
|
|
$custom_network_aliases
|
|
),
|
|
],
|
|
],
|
|
'mem_limit' => $this->application->limits_memory,
|
|
'memswap_limit' => $this->application->limits_memory_swap,
|
|
'mem_swappiness' => $this->application->limits_memory_swappiness,
|
|
'mem_reservation' => $this->application->limits_memory_reservation,
|
|
'cpus' => (float) $this->application->limits_cpus,
|
|
'cpu_shares' => $this->application->limits_cpu_shares,
|
|
],
|
|
],
|
|
'networks' => [
|
|
$this->destination->network => [
|
|
'external' => true,
|
|
'name' => $this->destination->network,
|
|
'attachable' => true,
|
|
],
|
|
],
|
|
];
|
|
// Always use .env file
|
|
$docker_compose['services'][$this->container_name]['env_file'] = ['.env'];
|
|
|
|
if ($this->application->settings->stop_grace_period !== null) {
|
|
$docker_compose['services'][$this->container_name]['stop_grace_period'] = $this->application->settings->stopGracePeriodSeconds().'s';
|
|
}
|
|
|
|
// An enabled Coolify healthcheck replaces any HEALTHCHECK from the image or Dockerfile.
|
|
// When it is disabled, the container keeps the HEALTHCHECK of the image (if any).
|
|
if (! $this->application->isHealthcheckDisabled()) {
|
|
$healthcheck_command = $this->generate_healthcheck_commands();
|
|
if ($healthcheck_command !== null) {
|
|
$docker_compose['services'][$this->container_name]['healthcheck'] = [
|
|
'test' => [
|
|
'CMD-SHELL',
|
|
$healthcheck_command,
|
|
],
|
|
'interval' => $this->application->health_check_interval.'s',
|
|
'timeout' => $this->application->health_check_timeout.'s',
|
|
'retries' => $this->application->health_check_retries,
|
|
'start_period' => $this->application->health_check_start_period.'s',
|
|
];
|
|
}
|
|
}
|
|
|
|
if (! is_null($this->application->limits_cpuset)) {
|
|
data_set($docker_compose, 'services.'.$this->container_name.'.cpuset', $this->application->limits_cpuset);
|
|
}
|
|
if ($this->mainServer->isSwarm()) {
|
|
data_forget($docker_compose, 'services.'.$this->container_name.'.container_name');
|
|
data_forget($docker_compose, 'services.'.$this->container_name.'.expose');
|
|
data_forget($docker_compose, 'services.'.$this->container_name.'.restart');
|
|
|
|
data_forget($docker_compose, 'services.'.$this->container_name.'.mem_limit');
|
|
data_forget($docker_compose, 'services.'.$this->container_name.'.memswap_limit');
|
|
data_forget($docker_compose, 'services.'.$this->container_name.'.mem_swappiness');
|
|
data_forget($docker_compose, 'services.'.$this->container_name.'.mem_reservation');
|
|
data_forget($docker_compose, 'services.'.$this->container_name.'.cpus');
|
|
data_forget($docker_compose, 'services.'.$this->container_name.'.cpuset');
|
|
data_forget($docker_compose, 'services.'.$this->container_name.'.cpu_shares');
|
|
|
|
$docker_compose['services'][$this->container_name]['deploy'] = [
|
|
'mode' => 'replicated',
|
|
'replicas' => data_get($this->application, 'swarm_replicas', 1),
|
|
'update_config' => [
|
|
'order' => 'start-first',
|
|
],
|
|
'rollback_config' => [
|
|
'order' => 'start-first',
|
|
],
|
|
'labels' => $labels,
|
|
'resources' => [
|
|
'limits' => [
|
|
'cpus' => $this->application->limits_cpus,
|
|
'memory' => $this->application->limits_memory,
|
|
],
|
|
'reservations' => [
|
|
'cpus' => $this->application->limits_cpus,
|
|
'memory' => $this->application->limits_memory,
|
|
],
|
|
],
|
|
];
|
|
if (data_get($this->application, 'swarm_placement_constraints')) {
|
|
$swarm_placement_constraints = Yaml::parse(base64_decode(data_get($this->application, 'swarm_placement_constraints')));
|
|
$docker_compose['services'][$this->container_name]['deploy'] = array_merge(
|
|
$docker_compose['services'][$this->container_name]['deploy'],
|
|
$swarm_placement_constraints
|
|
);
|
|
}
|
|
if (data_get($this->application, 'settings.is_swarm_only_worker_nodes')) {
|
|
$docker_compose['services'][$this->container_name]['deploy']['placement']['constraints'][] = 'node.role == worker';
|
|
}
|
|
if ($this->pull_request_id !== 0) {
|
|
$docker_compose['services'][$this->container_name]['deploy']['replicas'] = 1;
|
|
}
|
|
} else {
|
|
$docker_compose['services'][$this->container_name]['labels'] = $labels;
|
|
}
|
|
if ($this->mainServer->isLogDrainEnabled() && $this->application->isLogDrainEnabled()) {
|
|
$docker_compose['services'][$this->container_name]['logging'] = generate_log_drain_configuration($this->mainServer);
|
|
}
|
|
if ($this->application->settings->is_gpu_enabled) {
|
|
$docker_compose['services'][$this->container_name]['deploy']['resources']['reservations']['devices'] = [
|
|
$this->application->settings->gpuDeviceReservation(),
|
|
];
|
|
if (ApplicationSetting::parseGpuOptions($this->application->settings->gpu_options) === null) {
|
|
$this->application_deployment_queue->addLogEntry('GPU options are not a valid key=value list or YAML mapping. They are ignored.');
|
|
}
|
|
}
|
|
if ($this->application->isHealthcheckDisabled()) {
|
|
data_forget($docker_compose, 'services.'.$this->container_name.'.healthcheck');
|
|
}
|
|
if (count($this->application->ports_mappings_array) > 0 && $this->pull_request_id === 0) {
|
|
$docker_compose['services'][$this->container_name]['ports'] = $this->application->ports_mappings_array;
|
|
}
|
|
|
|
if (count($persistent_storages) > 0) {
|
|
if (! data_get($docker_compose, 'services.'.$this->container_name.'.volumes')) {
|
|
$docker_compose['services'][$this->container_name]['volumes'] = [];
|
|
}
|
|
$docker_compose['services'][$this->container_name]['volumes'] = array_merge($docker_compose['services'][$this->container_name]['volumes'], $persistent_storages);
|
|
}
|
|
if (count($persistent_file_volumes) > 0) {
|
|
if (! data_get($docker_compose, 'services.'.$this->container_name.'.volumes')) {
|
|
$docker_compose['services'][$this->container_name]['volumes'] = [];
|
|
}
|
|
$docker_compose['services'][$this->container_name]['volumes'] = array_merge($docker_compose['services'][$this->container_name]['volumes'], $persistent_file_volumes->map(function ($item) {
|
|
return "$item->fs_path:$item->mount_path";
|
|
})->toArray());
|
|
}
|
|
if (count($volume_names) > 0) {
|
|
$docker_compose['volumes'] = $volume_names;
|
|
}
|
|
|
|
if ($this->pull_request_id === 0) {
|
|
$custom_compose = convertDockerRunToCompose($this->application->custom_docker_run_options);
|
|
if ($this->application->settings->is_gpu_enabled && data_get($custom_compose, 'deploy.resources.reservations.devices')) {
|
|
data_forget($custom_compose, 'deploy.resources.reservations.devices');
|
|
$this->application_deployment_queue->addLogEntry('GPU access is enabled in the GPU settings, so --gpus in Custom Docker Options is ignored.');
|
|
}
|
|
$unsupported_options = unsupportedDockerRunOptions($this->application->custom_docker_run_options);
|
|
if (count($unsupported_options) > 0) {
|
|
$this->application_deployment_queue->addLogEntry('Warning: These custom Docker options are not supported and were not applied: '.implode(', ', $unsupported_options).'. See https://coolify.io/docs/applications/builds/custom-docker-options for the supported options.', 'stderr');
|
|
}
|
|
if ((bool) $this->application->settings->is_consistent_container_name_enabled) {
|
|
$docker_compose['services'][$this->application->uuid] = $docker_compose['services'][$this->container_name];
|
|
if ($this->container_name !== $this->application->uuid) {
|
|
unset($docker_compose['services'][$this->container_name]);
|
|
}
|
|
if (count($custom_compose) > 0) {
|
|
$ipv4 = data_get($custom_compose, 'ip.0');
|
|
$ipv6 = data_get($custom_compose, 'ip6.0');
|
|
data_forget($custom_compose, 'ip');
|
|
data_forget($custom_compose, 'ip6');
|
|
if ($ipv4) {
|
|
$docker_compose['services'][$this->application->uuid]['networks'][$this->destination->network]['ipv4_address'] = $ipv4;
|
|
}
|
|
if ($ipv6) {
|
|
$docker_compose['services'][$this->application->uuid]['networks'][$this->destination->network]['ipv6_address'] = $ipv6;
|
|
}
|
|
$docker_compose['services'][$this->application->uuid] = array_merge_recursive($docker_compose['services'][$this->application->uuid], $custom_compose);
|
|
}
|
|
} else {
|
|
if (count($custom_compose) > 0) {
|
|
$ipv4 = data_get($custom_compose, 'ip.0');
|
|
$ipv6 = data_get($custom_compose, 'ip6.0');
|
|
data_forget($custom_compose, 'ip');
|
|
data_forget($custom_compose, 'ip6');
|
|
if ($ipv4 || $ipv6) {
|
|
data_forget($docker_compose['services'][$this->container_name], 'networks');
|
|
}
|
|
if ($ipv4) {
|
|
$docker_compose['services'][$this->container_name]['networks'][$this->destination->network]['ipv4_address'] = $ipv4;
|
|
}
|
|
if ($ipv6) {
|
|
$docker_compose['services'][$this->container_name]['networks'][$this->destination->network]['ipv6_address'] = $ipv6;
|
|
}
|
|
$docker_compose['services'][$this->container_name] = array_merge_recursive($docker_compose['services'][$this->container_name], $custom_compose);
|
|
}
|
|
}
|
|
}
|
|
|
|
$this->docker_compose = Yaml::dump($docker_compose, 10);
|
|
$this->generated_docker_compose = $this->docker_compose;
|
|
}
|
|
|
|
/**
|
|
* The workdir is the build context, so the generated compose file is written only after the image
|
|
* build. Otherwise `COPY . .`, Nixpacks, and Railpack put it (with the proxy labels) into the image.
|
|
*/
|
|
private function write_compose_file_to_workdir(): void
|
|
{
|
|
$this->execute_remote_command([
|
|
"docker exec {$this->deployment_uuid} rm -f {$this->workdir}/docker-compose.yaml",
|
|
'hidden' => true,
|
|
], [
|
|
"docker exec -i {$this->deployment_uuid} tee {$this->workdir}/docker-compose.yaml > /dev/null",
|
|
'input' => $this->docker_compose,
|
|
'hidden' => true,
|
|
]);
|
|
}
|
|
|
|
private function generate_local_persistent_volumes()
|
|
{
|
|
$local_persistent_volumes = [];
|
|
foreach ($this->application->persistentStorages as $persistentStorage) {
|
|
if ($persistentStorage->host_path !== '' && $persistentStorage->host_path !== null) {
|
|
$volume_name = $persistentStorage->host_path;
|
|
} else {
|
|
$volume_name = $persistentStorage->name;
|
|
}
|
|
$isPreviewSuffixEnabled = (bool) data_get($persistentStorage, 'is_preview_suffix_enabled', true);
|
|
if ($this->pull_request_id !== 0 && $isPreviewSuffixEnabled) {
|
|
$volume_name = addPreviewDeploymentSuffix($volume_name, $this->pull_request_id);
|
|
}
|
|
$local_persistent_volumes[] = $volume_name.':'.$persistentStorage->mount_path;
|
|
}
|
|
|
|
return $local_persistent_volumes;
|
|
}
|
|
|
|
private function generate_local_persistent_volumes_only_volume_names()
|
|
{
|
|
$local_persistent_volumes_names = [];
|
|
foreach ($this->application->persistentStorages as $persistentStorage) {
|
|
if ($persistentStorage->host_path) {
|
|
continue;
|
|
}
|
|
$name = $persistentStorage->name;
|
|
|
|
$isPreviewSuffixEnabled = (bool) data_get($persistentStorage, 'is_preview_suffix_enabled', true);
|
|
if ($this->pull_request_id !== 0 && $isPreviewSuffixEnabled) {
|
|
$name = addPreviewDeploymentSuffix($name, $this->pull_request_id);
|
|
}
|
|
|
|
$local_persistent_volumes_names[$name] = [
|
|
'name' => $name,
|
|
'external' => false,
|
|
];
|
|
}
|
|
|
|
return $local_persistent_volumes_names;
|
|
}
|
|
|
|
private function generate_healthcheck_commands()
|
|
{
|
|
// Handle CMD type healthcheck
|
|
if ($this->application->health_check_type === 'cmd' && ! empty($this->application->health_check_command)) {
|
|
$command = str_replace(["\r\n", "\r", "\n"], ' ', $this->application->health_check_command);
|
|
|
|
// Defense in depth: validate command at runtime (matches input validation regex)
|
|
if (! preg_match('/^[a-zA-Z0-9 \-_.\/:=@,+]+$/', $command) || strlen($command) > 1000) {
|
|
$this->application_deployment_queue->addLogEntry('Warning: Health check command contains invalid characters or exceeds max length. Falling back to HTTP healthcheck.');
|
|
} else {
|
|
$this->full_healthcheck_url = $command;
|
|
|
|
return $command;
|
|
}
|
|
}
|
|
|
|
// HTTP type healthcheck (default)
|
|
if (! $this->application->health_check_port) {
|
|
if (! empty($this->application->ports_exposes_array)) {
|
|
$health_check_port = (int) $this->application->ports_exposes_array[0];
|
|
} else {
|
|
return null;
|
|
}
|
|
} else {
|
|
$health_check_port = (int) $this->application->health_check_port;
|
|
}
|
|
if ($this->application->settings->is_static || $this->application->build_pack === 'static') {
|
|
$health_check_port = 80;
|
|
}
|
|
|
|
$method = $this->sanitizeHealthCheckValue($this->application->health_check_method, '/^[A-Z]+$/', 'GET');
|
|
$scheme = $this->sanitizeHealthCheckValue($this->application->health_check_scheme, '/^https?$/', 'http');
|
|
$host = ValidationPatterns::isValidHealthCheckHost($this->application->health_check_host)
|
|
? formatHostForUrl($this->application->health_check_host)
|
|
: 'localhost';
|
|
$path = $this->application->health_check_path
|
|
? $this->sanitizeHealthCheckValue($this->application->health_check_path, '#^[a-zA-Z0-9/\-_.~%,;]+$#', '/')
|
|
: null;
|
|
|
|
$url = escapeshellarg("{$scheme}://{$host}:{$health_check_port}".($path ?? '/'));
|
|
$escapedMethod = escapeshellarg($method);
|
|
|
|
if ($path) {
|
|
$this->full_healthcheck_url = "{$method}: {$scheme}://{$host}:{$health_check_port}{$path}";
|
|
} else {
|
|
$this->full_healthcheck_url = "{$method}: {$scheme}://{$host}:{$health_check_port}/";
|
|
}
|
|
|
|
$generated_healthchecks_commands = [
|
|
"curl -s -X {$escapedMethod} -f {$url} > /dev/null || wget -q -O- {$url} > /dev/null || exit 1",
|
|
];
|
|
|
|
return implode(' ', $generated_healthchecks_commands);
|
|
}
|
|
|
|
private function sanitizeHealthCheckValue(string $value, string $pattern, string $default): string
|
|
{
|
|
if (preg_match($pattern, $value)) {
|
|
return $value;
|
|
}
|
|
|
|
return $default;
|
|
}
|
|
|
|
/**
|
|
* Older versions accepted any static image through the API, so a stored value can be one that
|
|
* Coolify no longer supports. Such a value stops the deployment with a clear error instead of
|
|
* silently building with another web server image.
|
|
*/
|
|
private function staticImage(): string
|
|
{
|
|
$image = StaticImageTypes::tryFrom((string) $this->application->static_image);
|
|
|
|
if ($image === null) {
|
|
$supported = implode(', ', array_column(StaticImageTypes::cases(), 'value'));
|
|
|
|
throw new DeploymentException("The static image '{$this->application->static_image}' is not supported. Select a supported web server ({$supported}) in the application settings and deploy again.");
|
|
}
|
|
|
|
return $image->value;
|
|
}
|
|
|
|
private function pull_latest_image(string $image): void
|
|
{
|
|
$image = StaticImageTypes::from($image)->value;
|
|
$this->application_deployment_queue->addLogEntry("Pulling latest image ($image) from the registry.");
|
|
$this->execute_remote_command(
|
|
[
|
|
executeInDocker($this->deployment_uuid, 'docker pull '.escapeshellarg($image)),
|
|
'hidden' => true,
|
|
]
|
|
);
|
|
}
|
|
|
|
private function build_static_image()
|
|
{
|
|
$this->application_deployment_queue->addLogEntry('----------------------------------------');
|
|
$this->application_deployment_queue->addLogEntry('Static deployment. Copying static assets to the image.');
|
|
if ($this->application->static_image) {
|
|
$this->pull_latest_image($this->staticImage());
|
|
}
|
|
$dockerfile = base64_encode("FROM {$this->staticImage()}
|
|
WORKDIR /usr/share/nginx/html/
|
|
LABEL coolify.deploymentId={$this->deployment_uuid}
|
|
COPY . .
|
|
RUN rm -f /usr/share/nginx/html/nginx.conf
|
|
RUN rm -f /usr/share/nginx/html/Dockerfile
|
|
RUN rm -f /usr/share/nginx/html/docker-compose.yaml
|
|
RUN rm -f /usr/share/nginx/html/.env
|
|
COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
|
|
if (str($this->application->custom_nginx_configuration)->isNotEmpty()) {
|
|
$nginx_config = base64_encode($this->application->custom_nginx_configuration);
|
|
} else {
|
|
if ($this->application->settings->is_spa) {
|
|
$nginx_config = base64_encode(defaultNginxConfiguration('spa'));
|
|
} else {
|
|
$nginx_config = base64_encode(defaultNginxConfiguration());
|
|
}
|
|
}
|
|
if ($this->dockerBuildkitSupported) {
|
|
$build_command = "DOCKER_BUILDKIT=1 docker build {$this->addHosts} --network host -f {$this->workdir}/Dockerfile --progress plain -t {$this->production_image_name} {$this->workdir}";
|
|
} else {
|
|
$build_command = "docker build {$this->addHosts} --network host -f {$this->workdir}/Dockerfile -t {$this->production_image_name} {$this->workdir}";
|
|
}
|
|
$base64_build_command = base64_encode($build_command);
|
|
$this->execute_remote_command(
|
|
[
|
|
executeInDocker($this->deployment_uuid, "echo '{$dockerfile}' | base64 -d | tee {$this->workdir}/Dockerfile > /dev/null"),
|
|
],
|
|
[
|
|
executeInDocker($this->deployment_uuid, "echo '{$nginx_config}' | base64 -d | tee {$this->workdir}/nginx.conf > /dev/null"),
|
|
],
|
|
[
|
|
executeInDocker($this->deployment_uuid, "echo '{$base64_build_command}' | base64 -d | tee ".self::BUILD_SCRIPT_PATH.' > /dev/null'),
|
|
'hidden' => true,
|
|
],
|
|
[
|
|
executeInDocker($this->deployment_uuid, 'cat '.self::BUILD_SCRIPT_PATH),
|
|
'hidden' => true,
|
|
],
|
|
[
|
|
executeInDocker($this->deployment_uuid, 'bash '.self::BUILD_SCRIPT_PATH),
|
|
'hidden' => true,
|
|
]
|
|
);
|
|
$this->application_deployment_queue->addLogEntry('Building docker image completed.');
|
|
}
|
|
|
|
/**
|
|
* Wrap a docker build command with environment export from build-time .env file
|
|
* This enables shell interpolation of variables (e.g., APP_URL=$COOLIFY_URL)
|
|
*
|
|
* @param string $build_command The docker build command to wrap
|
|
* @return string The wrapped command with export statement
|
|
*/
|
|
private function wrap_build_command_with_env_export(string $build_command): string
|
|
{
|
|
if (! $this->useBuildtimeEnvironmentLauncher) {
|
|
return "cd {$this->workdir} && set -a && source ".self::BUILD_TIME_ENV_PATH." && set +a && {$build_command}";
|
|
}
|
|
|
|
$escapedBuildCommand = escapeBashEnvValue($build_command);
|
|
|
|
return "cd {$this->workdir} && bash ".self::BUILD_TIME_ENV_LAUNCHER_PATH." /bin/bash -c {$escapedBuildCommand}";
|
|
}
|
|
|
|
private function build_image()
|
|
{
|
|
// Add Coolify related variables to the build args/secrets
|
|
if (! $this->dockerSecretsSupported) {
|
|
// Traditional build args approach - generate COOLIFY_ variables locally
|
|
$coolify_envs = $this->generate_coolify_env_variables(forBuildTime: true);
|
|
$coolify_envs->each(function ($value, $key) {
|
|
$key = $this->validatedBuildtimeEnvironmentVariableKey((string) $key, 'the Coolify build environment');
|
|
$this->build_args->push('--build-arg '.escapeshellarg($key));
|
|
});
|
|
}
|
|
|
|
// Always convert build_args Collection to string for command interpolation
|
|
$this->build_args = $this->build_args instanceof Collection
|
|
? $this->build_args->implode(' ')
|
|
: (string) $this->build_args;
|
|
|
|
$this->application_deployment_queue->addLogEntry('----------------------------------------');
|
|
if ($this->disableBuildCache) {
|
|
$this->application_deployment_queue->addLogEntry('Docker build cache is disabled. It will not be used during the build process.');
|
|
}
|
|
if ($this->application->build_pack === 'static') {
|
|
$this->application_deployment_queue->addLogEntry('Static deployment. Copying static assets to the image.');
|
|
} else {
|
|
$this->application_deployment_queue->addLogEntry('Building docker image started.');
|
|
$this->application_deployment_queue->addLogEntry('To check the current progress, click on Show Debug Logs.');
|
|
}
|
|
|
|
if ($this->application->settings->is_static) {
|
|
if ($this->application->static_image) {
|
|
$this->pull_latest_image($this->staticImage());
|
|
$this->application_deployment_queue->addLogEntry('Continuing with the building process.');
|
|
}
|
|
if ($this->application->build_pack === 'nixpacks') {
|
|
$this->nixpacks_plan = base64_encode($this->nixpacks_plan);
|
|
$this->execute_remote_command([executeInDocker($this->deployment_uuid, "echo '{$this->nixpacks_plan}' | base64 -d | tee ".self::NIXPACKS_PLAN_PATH.' > /dev/null'), 'hidden' => true, 'skip_command_log' => true]);
|
|
if ($this->force_rebuild) {
|
|
$this->execute_remote_command([
|
|
executeInDocker($this->deployment_uuid, 'nixpacks build -c '.self::NIXPACKS_PLAN_PATH." --no-cache --no-error-without-start -n {$this->build_image_name} {$this->workdir} -o {$this->workdir}"),
|
|
'hidden' => true,
|
|
], [
|
|
executeInDocker($this->deployment_uuid, "cat {$this->workdir}/.nixpacks/Dockerfile"),
|
|
'hidden' => true,
|
|
]);
|
|
if ($this->dockerSecretsSupported) {
|
|
// Modify the nixpacks Dockerfile to use build secrets
|
|
$this->modify_dockerfile_for_secrets("{$this->workdir}/.nixpacks/Dockerfile");
|
|
$secrets_flags = $this->build_secrets ? " {$this->build_secrets}" : '';
|
|
$build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build --no-cache {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile{$secrets_flags} --progress plain -t {$this->build_image_name} {$this->workdir}");
|
|
} elseif ($this->dockerBuildkitSupported) {
|
|
// BuildKit without secrets
|
|
$build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build --no-cache {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile --progress plain -t {$this->build_image_name} {$this->build_args} {$this->workdir}");
|
|
} else {
|
|
$build_command = $this->wrap_build_command_with_env_export("docker build --no-cache {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile -t {$this->build_image_name} {$this->build_args} {$this->workdir}");
|
|
}
|
|
} else {
|
|
$this->execute_remote_command([
|
|
executeInDocker($this->deployment_uuid, 'nixpacks build -c '.self::NIXPACKS_PLAN_PATH." --cache-key '{$this->application->uuid}' --no-error-without-start -n {$this->build_image_name} {$this->workdir} -o {$this->workdir}"),
|
|
'hidden' => true,
|
|
], [
|
|
executeInDocker($this->deployment_uuid, "cat {$this->workdir}/.nixpacks/Dockerfile"),
|
|
'hidden' => true,
|
|
]);
|
|
if ($this->dockerSecretsSupported) {
|
|
// Modify the nixpacks Dockerfile to use build secrets
|
|
$this->modify_dockerfile_for_secrets("{$this->workdir}/.nixpacks/Dockerfile");
|
|
$secrets_flags = $this->build_secrets ? " {$this->build_secrets}" : '';
|
|
$build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile{$secrets_flags} --progress plain -t {$this->build_image_name} {$this->workdir}");
|
|
} elseif ($this->dockerBuildkitSupported) {
|
|
// BuildKit without secrets
|
|
$build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile --progress plain -t {$this->build_image_name} {$this->build_args} {$this->workdir}");
|
|
} else {
|
|
$build_command = $this->wrap_build_command_with_env_export("docker build {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile -t {$this->build_image_name} {$this->build_args} {$this->workdir}");
|
|
}
|
|
}
|
|
|
|
$base64_build_command = base64_encode($build_command);
|
|
$this->execute_remote_command(
|
|
[
|
|
executeInDocker($this->deployment_uuid, "echo '{$base64_build_command}' | base64 -d | tee ".self::BUILD_SCRIPT_PATH.' > /dev/null'),
|
|
'hidden' => true,
|
|
],
|
|
[
|
|
executeInDocker($this->deployment_uuid, 'cat '.self::BUILD_SCRIPT_PATH),
|
|
'hidden' => true,
|
|
],
|
|
[
|
|
executeInDocker($this->deployment_uuid, 'bash '.self::BUILD_SCRIPT_PATH),
|
|
'hidden' => true,
|
|
]
|
|
);
|
|
$this->execute_remote_command([executeInDocker($this->deployment_uuid, 'rm '.self::NIXPACKS_PLAN_PATH), 'hidden' => true]);
|
|
} else {
|
|
// Dockerfile buildpack
|
|
if ($this->dockerSecretsSupported) {
|
|
// Modify the Dockerfile to use build secrets
|
|
$this->modify_dockerfile_for_secrets("{$this->workdir}{$this->dockerfile_location}");
|
|
$secrets_flags = $this->build_secrets ? " {$this->build_secrets}" : '';
|
|
if ($this->force_rebuild) {
|
|
$build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build --no-cache {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location}{$secrets_flags} --progress plain -t $this->build_image_name {$this->workdir}");
|
|
} else {
|
|
$build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location}{$secrets_flags} --progress plain -t $this->build_image_name {$this->workdir}");
|
|
}
|
|
} elseif ($this->dockerBuildkitSupported) {
|
|
// BuildKit without secrets
|
|
if ($this->force_rebuild) {
|
|
$build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build --no-cache {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location} --progress plain -t $this->build_image_name {$this->build_args} {$this->workdir}");
|
|
} else {
|
|
$build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location} --progress plain -t $this->build_image_name {$this->build_args} {$this->workdir}");
|
|
}
|
|
} else {
|
|
// Traditional build with args
|
|
if ($this->force_rebuild) {
|
|
$build_command = $this->wrap_build_command_with_env_export("docker build --no-cache {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location} {$this->build_args} -t $this->build_image_name {$this->workdir}");
|
|
} else {
|
|
$build_command = $this->wrap_build_command_with_env_export("docker build {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location} {$this->build_args} -t $this->build_image_name {$this->workdir}");
|
|
}
|
|
}
|
|
$base64_build_command = base64_encode($build_command);
|
|
$this->execute_remote_command(
|
|
[
|
|
executeInDocker($this->deployment_uuid, "echo '{$base64_build_command}' | base64 -d | tee ".self::BUILD_SCRIPT_PATH.' > /dev/null'),
|
|
'hidden' => true,
|
|
],
|
|
[
|
|
executeInDocker($this->deployment_uuid, 'cat '.self::BUILD_SCRIPT_PATH),
|
|
'hidden' => true,
|
|
],
|
|
[
|
|
executeInDocker($this->deployment_uuid, 'bash '.self::BUILD_SCRIPT_PATH),
|
|
'hidden' => true,
|
|
]
|
|
);
|
|
}
|
|
$publishDir = trim($this->application->publish_directory, '/');
|
|
$publishDir = $publishDir ? "/{$publishDir}" : '';
|
|
$dockerfile = base64_encode("FROM {$this->staticImage()}
|
|
WORKDIR /usr/share/nginx/html/
|
|
LABEL coolify.deploymentId={$this->deployment_uuid}
|
|
COPY --from=$this->build_image_name /app{$publishDir} .
|
|
COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
|
|
if (str($this->application->custom_nginx_configuration)->isNotEmpty()) {
|
|
$nginx_config = base64_encode($this->application->custom_nginx_configuration);
|
|
} else {
|
|
if ($this->application->settings->is_spa) {
|
|
$nginx_config = base64_encode(defaultNginxConfiguration('spa'));
|
|
} else {
|
|
$nginx_config = base64_encode(defaultNginxConfiguration());
|
|
}
|
|
}
|
|
if ($this->dockerBuildkitSupported) {
|
|
$build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build {$this->addHosts} --network host -f {$this->workdir}/Dockerfile {$this->build_args} --progress plain -t {$this->production_image_name} {$this->workdir}");
|
|
} else {
|
|
$build_command = $this->wrap_build_command_with_env_export("docker build {$this->addHosts} --network host -f {$this->workdir}/Dockerfile {$this->build_args} -t {$this->production_image_name} {$this->workdir}");
|
|
}
|
|
$base64_build_command = base64_encode($build_command);
|
|
$this->execute_remote_command(
|
|
[
|
|
executeInDocker($this->deployment_uuid, "echo '{$dockerfile}' | base64 -d | tee {$this->workdir}/Dockerfile > /dev/null"),
|
|
],
|
|
[
|
|
executeInDocker($this->deployment_uuid, "echo '{$nginx_config}' | base64 -d | tee {$this->workdir}/nginx.conf > /dev/null"),
|
|
],
|
|
[
|
|
executeInDocker($this->deployment_uuid, "echo '{$base64_build_command}' | base64 -d | tee ".self::BUILD_SCRIPT_PATH.' > /dev/null'),
|
|
'hidden' => true,
|
|
],
|
|
[
|
|
executeInDocker($this->deployment_uuid, 'cat '.self::BUILD_SCRIPT_PATH),
|
|
'hidden' => true,
|
|
],
|
|
[
|
|
executeInDocker($this->deployment_uuid, 'bash '.self::BUILD_SCRIPT_PATH),
|
|
'hidden' => true,
|
|
]
|
|
);
|
|
} else {
|
|
// Pure Dockerfile based deployment
|
|
if ($this->application->dockerfile) {
|
|
if ($this->dockerSecretsSupported) {
|
|
// Modify the Dockerfile to use build secrets
|
|
$this->modify_dockerfile_for_secrets("{$this->workdir}{$this->dockerfile_location}");
|
|
$secrets_flags = $this->build_secrets ? " {$this->build_secrets}" : '';
|
|
if ($this->force_rebuild) {
|
|
$build_command = "DOCKER_BUILDKIT=1 docker build --no-cache --pull {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location}{$secrets_flags} --progress plain -t {$this->production_image_name} {$this->workdir}";
|
|
} else {
|
|
$build_command = "DOCKER_BUILDKIT=1 docker build --pull {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location}{$secrets_flags} --progress plain -t {$this->production_image_name} {$this->workdir}";
|
|
}
|
|
} elseif ($this->dockerBuildkitSupported) {
|
|
// BuildKit without secrets
|
|
if ($this->force_rebuild) {
|
|
$build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build --no-cache --pull {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location} --progress plain -t {$this->production_image_name} {$this->build_args} {$this->workdir}");
|
|
} else {
|
|
$build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build --pull {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location} --progress plain -t {$this->production_image_name} {$this->build_args} {$this->workdir}");
|
|
}
|
|
} else {
|
|
// Traditional build with args (no --progress for legacy builder compatibility)
|
|
if ($this->force_rebuild) {
|
|
$build_command = $this->wrap_build_command_with_env_export("docker build --no-cache --pull {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location} {$this->build_args} -t {$this->production_image_name} {$this->workdir}");
|
|
} else {
|
|
$build_command = $this->wrap_build_command_with_env_export("docker build --pull {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location} {$this->build_args} -t {$this->production_image_name} {$this->workdir}");
|
|
}
|
|
}
|
|
$base64_build_command = base64_encode($build_command);
|
|
$this->execute_remote_command(
|
|
[
|
|
executeInDocker($this->deployment_uuid, "echo '{$base64_build_command}' | base64 -d | tee ".self::BUILD_SCRIPT_PATH.' > /dev/null'),
|
|
'hidden' => true,
|
|
],
|
|
[
|
|
executeInDocker($this->deployment_uuid, 'cat '.self::BUILD_SCRIPT_PATH),
|
|
'hidden' => true,
|
|
],
|
|
[
|
|
executeInDocker($this->deployment_uuid, 'bash '.self::BUILD_SCRIPT_PATH),
|
|
'hidden' => true,
|
|
]
|
|
);
|
|
} else {
|
|
if ($this->application->build_pack === 'nixpacks') {
|
|
$this->nixpacks_plan = base64_encode($this->nixpacks_plan);
|
|
$this->execute_remote_command([executeInDocker($this->deployment_uuid, "echo '{$this->nixpacks_plan}' | base64 -d | tee ".self::NIXPACKS_PLAN_PATH.' > /dev/null'), 'hidden' => true, 'skip_command_log' => true]);
|
|
if ($this->force_rebuild) {
|
|
$this->execute_remote_command([
|
|
executeInDocker($this->deployment_uuid, 'nixpacks build -c '.self::NIXPACKS_PLAN_PATH." --no-cache --no-error-without-start -n {$this->production_image_name} {$this->workdir} -o {$this->workdir}"),
|
|
'hidden' => true,
|
|
], [
|
|
executeInDocker($this->deployment_uuid, "cat {$this->workdir}/.nixpacks/Dockerfile"),
|
|
'hidden' => true,
|
|
]);
|
|
if ($this->dockerSecretsSupported) {
|
|
// Modify the nixpacks Dockerfile to use build secrets
|
|
$this->modify_dockerfile_for_secrets("{$this->workdir}/.nixpacks/Dockerfile");
|
|
$secrets_flags = $this->build_secrets ? " {$this->build_secrets}" : '';
|
|
$build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build --no-cache {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile{$secrets_flags} --progress plain -t {$this->production_image_name} {$this->workdir}");
|
|
} elseif ($this->dockerBuildkitSupported) {
|
|
// BuildKit without secrets
|
|
$build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build --no-cache {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile --progress plain -t {$this->production_image_name} {$this->build_args} {$this->workdir}");
|
|
} else {
|
|
$build_command = $this->wrap_build_command_with_env_export("docker build --no-cache {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile -t {$this->production_image_name} {$this->build_args} {$this->workdir}");
|
|
}
|
|
} else {
|
|
$this->execute_remote_command([
|
|
executeInDocker($this->deployment_uuid, 'nixpacks build -c '.self::NIXPACKS_PLAN_PATH." --cache-key '{$this->application->uuid}' --no-error-without-start -n {$this->production_image_name} {$this->workdir} -o {$this->workdir}"),
|
|
'hidden' => true,
|
|
], [
|
|
executeInDocker($this->deployment_uuid, "cat {$this->workdir}/.nixpacks/Dockerfile"),
|
|
'hidden' => true,
|
|
]);
|
|
if ($this->dockerSecretsSupported) {
|
|
// Modify the nixpacks Dockerfile to use build secrets
|
|
$this->modify_dockerfile_for_secrets("{$this->workdir}/.nixpacks/Dockerfile");
|
|
$secrets_flags = $this->build_secrets ? " {$this->build_secrets}" : '';
|
|
$build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile{$secrets_flags} --progress plain -t {$this->production_image_name} {$this->workdir}");
|
|
} elseif ($this->dockerBuildkitSupported) {
|
|
// BuildKit without secrets
|
|
$build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile --progress plain -t {$this->production_image_name} {$this->build_args} {$this->workdir}");
|
|
} else {
|
|
$build_command = $this->wrap_build_command_with_env_export("docker build {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile -t {$this->production_image_name} {$this->build_args} {$this->workdir}");
|
|
}
|
|
}
|
|
$base64_build_command = base64_encode($build_command);
|
|
$this->execute_remote_command(
|
|
[
|
|
executeInDocker($this->deployment_uuid, "echo '{$base64_build_command}' | base64 -d | tee ".self::BUILD_SCRIPT_PATH.' > /dev/null'),
|
|
'hidden' => true,
|
|
],
|
|
[
|
|
executeInDocker($this->deployment_uuid, 'cat '.self::BUILD_SCRIPT_PATH),
|
|
'hidden' => true,
|
|
],
|
|
[
|
|
executeInDocker($this->deployment_uuid, 'bash '.self::BUILD_SCRIPT_PATH),
|
|
'hidden' => true,
|
|
]
|
|
);
|
|
$this->execute_remote_command([executeInDocker($this->deployment_uuid, 'rm '.self::NIXPACKS_PLAN_PATH), 'hidden' => true]);
|
|
} else {
|
|
// Dockerfile buildpack
|
|
if ($this->dockerSecretsSupported) {
|
|
// Modify the Dockerfile to use build secrets
|
|
$this->modify_dockerfile_for_secrets("{$this->workdir}{$this->dockerfile_location}");
|
|
// Use BuildKit with secrets
|
|
$secrets_flags = $this->build_secrets ? " {$this->build_secrets}" : '';
|
|
if ($this->force_rebuild) {
|
|
$build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build --no-cache {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location}{$secrets_flags} --progress plain -t {$this->production_image_name} {$this->workdir}");
|
|
} else {
|
|
$build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location}{$secrets_flags} --progress plain -t {$this->production_image_name} {$this->workdir}");
|
|
}
|
|
} elseif ($this->dockerBuildkitSupported) {
|
|
// BuildKit without secrets
|
|
if ($this->force_rebuild) {
|
|
$build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build --no-cache {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location} --progress plain -t {$this->production_image_name} {$this->build_args} {$this->workdir}");
|
|
} else {
|
|
$build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location} --progress plain -t {$this->production_image_name} {$this->build_args} {$this->workdir}");
|
|
}
|
|
} else {
|
|
// Traditional build with args
|
|
if ($this->force_rebuild) {
|
|
$build_command = $this->wrap_build_command_with_env_export("docker build --no-cache {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location} {$this->build_args} -t {$this->production_image_name} {$this->workdir}");
|
|
} else {
|
|
$build_command = $this->wrap_build_command_with_env_export("docker build {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location} {$this->build_args} -t {$this->production_image_name} {$this->workdir}");
|
|
}
|
|
}
|
|
$base64_build_command = base64_encode($build_command);
|
|
$this->execute_remote_command(
|
|
[
|
|
executeInDocker($this->deployment_uuid, "echo '{$base64_build_command}' | base64 -d | tee ".self::BUILD_SCRIPT_PATH.' > /dev/null'),
|
|
'hidden' => true,
|
|
],
|
|
[
|
|
executeInDocker($this->deployment_uuid, 'cat '.self::BUILD_SCRIPT_PATH),
|
|
'hidden' => true,
|
|
],
|
|
[
|
|
executeInDocker($this->deployment_uuid, 'bash '.self::BUILD_SCRIPT_PATH),
|
|
'hidden' => true,
|
|
]
|
|
);
|
|
}
|
|
}
|
|
}
|
|
$this->application_deployment_queue->addLogEntry('Building docker image completed.');
|
|
}
|
|
|
|
private function graceful_shutdown_container(string $containerName, bool $skipRemove = false)
|
|
{
|
|
try {
|
|
$timeout = $this->application->settings->deploymentStopGracePeriodSeconds();
|
|
|
|
if ($skipRemove) {
|
|
// The helper container (started with --rm) must also stop when the deployment was cancelled.
|
|
$this->execute_remote_command(
|
|
[dockerStopCommand($timeout, escapeshellarg($containerName), $this->server), 'hidden' => true, 'ignore_errors' => true, 'skip_cancellation_check' => true]
|
|
);
|
|
} else {
|
|
$this->execute_remote_command(
|
|
[dockerStopCommand($timeout, escapeshellarg($containerName), $this->server), 'hidden' => true, 'ignore_errors' => true]
|
|
);
|
|
$this->removeContainerWithTimeout($containerName);
|
|
}
|
|
} catch (Exception $error) {
|
|
$this->application_deployment_queue->addLogEntry("Error stopping container $containerName: ".$error->getMessage(), 'stderr');
|
|
}
|
|
}
|
|
|
|
private function removeContainerWithTimeout(string $containerName): void
|
|
{
|
|
$outputKey = 'container_remove_'.md5($containerName);
|
|
|
|
$this->execute_remote_command([
|
|
dockerRemoveCommandWithTimeout($containerName),
|
|
'hidden' => true,
|
|
'ignore_errors' => true,
|
|
'save' => $outputKey,
|
|
'append' => false,
|
|
]);
|
|
|
|
if (! isset($this->saved_outputs)) {
|
|
return;
|
|
}
|
|
|
|
$output = (string) $this->saved_outputs->get($outputKey, '');
|
|
if (! str_contains($output, self::CONTAINER_REMOVE_TIMEOUT_MARKER)) {
|
|
return;
|
|
}
|
|
|
|
$this->application_deployment_queue->addLogEntry(
|
|
"Warning: Removing container {$containerName} timed out after 60 seconds. The deployment will continue and cleanup will be retried in 5 minutes.",
|
|
'stderr'
|
|
);
|
|
|
|
RemoveContainerJob::dispatch($this->server->id, $containerName)
|
|
->delay(now()->addMinutes(5));
|
|
}
|
|
|
|
private function stop_running_container(bool $force = false)
|
|
{
|
|
try {
|
|
$this->application_deployment_queue->addLogEntry('Removing old containers.');
|
|
if ($this->newVersionIsHealthy() || $force) {
|
|
if ($this->application->settings->is_consistent_container_name_enabled) {
|
|
$containers = getCurrentApplicationContainerStatus($this->server, $this->application, $this->pull_request_id);
|
|
$this->containerNamesToRemove($containers)->each(function (string $containerName) {
|
|
$this->graceful_shutdown_container($containerName);
|
|
});
|
|
} else {
|
|
$containers = getCurrentApplicationContainerStatus($this->server, $this->application, $this->pull_request_id);
|
|
$this->oldGeneratedContainerNames($containers)->each(function (string $containerName) {
|
|
$this->graceful_shutdown_container($containerName);
|
|
});
|
|
}
|
|
} else {
|
|
if ($this->application->dockerfile || $this->application->build_pack === 'dockerfile' || $this->application->build_pack === 'dockerimage') {
|
|
$this->application_deployment_queue->addLogEntry('----------------------------------------');
|
|
$this->application_deployment_queue->addLogEntry("WARNING: Dockerfile or Docker Image based deployment detected. The healthcheck needs a curl or wget command to check the health of the application. Please make sure that it is available in the image or turn off healthcheck on Coolify's UI.");
|
|
$this->application_deployment_queue->addLogEntry('----------------------------------------');
|
|
}
|
|
$this->application_deployment_queue->addLogEntry('New container is not healthy, rolling back to the old container.');
|
|
$this->failDeployment();
|
|
$this->graceful_shutdown_container($this->container_name);
|
|
}
|
|
} catch (Exception $e) {
|
|
// If new version is healthy, this is just cleanup - don't fail the deployment
|
|
if ($this->newVersionIsHealthy() || $force) {
|
|
$this->application_deployment_queue->addLogEntry(
|
|
"Warning: Could not remove old container: {$e->getMessage()}",
|
|
'stderr',
|
|
hidden: true
|
|
);
|
|
|
|
return; // Don't re-throw - cleanup failures shouldn't fail successful deployments
|
|
}
|
|
|
|
// Only re-throw if deployment hasn't succeeded yet
|
|
throw new DeploymentException("Failed to stop running container: {$e->getMessage()}", $e->getCode(), $e);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* The containers to remove when container names are generated per deployment: all except the
|
|
* new one, and after a rolling update only the ones that ran before its new container started.
|
|
*/
|
|
private function oldGeneratedContainerNames(Collection $containers): Collection
|
|
{
|
|
$names = $containers->pluck('Names')->filter();
|
|
if ($this->pull_request_id === 0) {
|
|
$names = $names->reject(fn (string $name) => $name === $this->container_name || $name === addPreviewDeploymentSuffix($this->container_name, $this->pull_request_id));
|
|
}
|
|
if ($this->containerNamesBeforeRollingUpdate !== null) {
|
|
$names = $names->filter(fn (string $name) => in_array($name, $this->containerNamesBeforeRollingUpdate, true));
|
|
}
|
|
|
|
return $names->values();
|
|
}
|
|
|
|
private function containerNamesToRemove(Collection $containers): Collection
|
|
{
|
|
return $containers
|
|
->pluck('Names')
|
|
->push($this->container_name)
|
|
->filter()
|
|
->unique()
|
|
->values();
|
|
}
|
|
|
|
/**
|
|
* `docker compose up` creates a missing bind source as an empty directory, so content files are
|
|
* written first. This also gives a server that was added later the files it does not have.
|
|
*/
|
|
private function start_by_compose_file()
|
|
{
|
|
$this->write_missing_content_files();
|
|
|
|
try {
|
|
// Ensure .env file exists before docker compose tries to load it (defensive programming)
|
|
$this->execute_remote_command(
|
|
["touch {$this->configuration_dir}/.env", 'hidden' => true],
|
|
[$this->destination->proxyConnectCommand(), 'hidden' => true, 'ignore_errors' => true],
|
|
);
|
|
|
|
if ($this->application->build_pack === 'dockerimage') {
|
|
$this->application_deployment_queue->addLogEntry('Pulling latest images from the registry.');
|
|
$this->execute_remote_command(
|
|
[executeInDocker($this->deployment_uuid, "docker compose --project-name {$this->application->uuid} --project-directory {$this->workdir} pull"), 'hidden' => true],
|
|
[executeInDocker($this->deployment_uuid, "{$this->coolify_variables} docker compose --project-name {$this->application->uuid} --project-directory {$this->workdir} up --build -d"), 'hidden' => true],
|
|
);
|
|
} else {
|
|
if ($this->use_build_server) {
|
|
$this->execute_remote_command(
|
|
[serverDockerConfigPrefix($this->server)."{$this->coolify_variables} docker compose --project-name {$this->application->uuid} --project-directory {$this->configuration_dir} -f {$this->configuration_dir}{$this->docker_compose_location} up --pull always --build -d", 'hidden' => true],
|
|
);
|
|
} else {
|
|
$this->execute_remote_command(
|
|
[executeInDocker($this->deployment_uuid, "{$this->coolify_variables} docker compose --project-name {$this->application->uuid} --project-directory {$this->workdir} -f {$this->workdir}{$this->docker_compose_location} up --build -d"), 'hidden' => true],
|
|
);
|
|
}
|
|
}
|
|
$this->application_deployment_queue->addLogEntry('New container started.');
|
|
} catch (Exception $e) {
|
|
throw new DeploymentException("Failed to start container: {$e->getMessage()}", $e->getCode(), $e);
|
|
}
|
|
}
|
|
|
|
private function analyzeBuildTimeVariables($variables)
|
|
{
|
|
$userDefinedVariables = collect([]);
|
|
|
|
$dbVariables = $this->pull_request_id === 0
|
|
? $this->application->environment_variables()
|
|
->where('is_buildtime', true)
|
|
->pluck('key')
|
|
: $this->application->environment_variables_preview()
|
|
->where('is_buildtime', true)
|
|
->pluck('key');
|
|
|
|
foreach ($variables as $key => $value) {
|
|
if ($dbVariables->contains($key)) {
|
|
$userDefinedVariables->put($key, $value);
|
|
}
|
|
}
|
|
|
|
if ($userDefinedVariables->isEmpty()) {
|
|
return;
|
|
}
|
|
|
|
$variablesArray = $userDefinedVariables->toArray();
|
|
$warnings = self::analyzeBuildVariables($variablesArray);
|
|
|
|
if (empty($warnings)) {
|
|
return;
|
|
}
|
|
$this->application_deployment_queue->addLogEntry('----------------------------------------');
|
|
foreach ($warnings as $warning) {
|
|
$messages = self::formatBuildWarning($warning);
|
|
foreach ($messages as $message) {
|
|
$this->application_deployment_queue->addLogEntry($message, type: 'warning');
|
|
}
|
|
$this->application_deployment_queue->addLogEntry('');
|
|
}
|
|
|
|
// Add general advice
|
|
$this->application_deployment_queue->addLogEntry('💡 Tips to resolve build issues:', type: 'info');
|
|
$this->application_deployment_queue->addLogEntry(' 1. Set these variables as "Runtime only" in the environment variables settings', type: 'info');
|
|
$this->application_deployment_queue->addLogEntry(' 2. Use different values for build-time (e.g., NODE_ENV=development for build)', type: 'info');
|
|
$this->application_deployment_queue->addLogEntry(' 3. Consider using multi-stage Docker builds to separate build and runtime environments', type: 'info');
|
|
}
|
|
|
|
private function generate_build_env_variables()
|
|
{
|
|
if ($this->application->build_pack === 'nixpacks') {
|
|
$variables = collect($this->nixpacks_plan_json->get('variables'));
|
|
} else {
|
|
$this->generate_env_variables();
|
|
$variables = collect([])->merge($this->env_args);
|
|
}
|
|
// Analyze build variables for potential issues
|
|
if ($variables->isNotEmpty()) {
|
|
$this->analyzeBuildTimeVariables($variables);
|
|
}
|
|
|
|
if ($this->dockerSecretsSupported) {
|
|
$this->generate_build_secrets($variables);
|
|
$this->build_args = '';
|
|
} else {
|
|
$secrets_hash = '';
|
|
if ($variables->isNotEmpty()) {
|
|
$secrets_hash = $this->generate_secrets_hash($variables);
|
|
}
|
|
|
|
$env_vars = $this->pull_request_id === 0
|
|
? $this->application->environment_variables()->where('is_buildtime', true)->get()
|
|
: $this->application->environment_variables_preview()->where('is_buildtime', true)->get();
|
|
|
|
// Map variables to include is_multiline flag
|
|
$vars_with_metadata = $variables->map(function ($value, $key) use ($env_vars) {
|
|
$env = $env_vars->firstWhere('key', $key);
|
|
|
|
return [
|
|
'key' => $key,
|
|
'value' => $value,
|
|
'is_multiline' => $env ? $env->is_multiline : false,
|
|
];
|
|
});
|
|
|
|
$this->build_args = generateDockerBuildArgs($vars_with_metadata);
|
|
|
|
if ($secrets_hash) {
|
|
$this->build_args->push('--build-arg '.escapeshellarg("COOLIFY_BUILD_SECRETS_HASH={$secrets_hash}"));
|
|
}
|
|
}
|
|
}
|
|
|
|
private function generate_docker_env_flags_for_secrets()
|
|
{
|
|
$this->helperBuildSecretEnvironmentKeys = [];
|
|
|
|
// Only generate env flags if build secrets are enabled and the deployment builds an image
|
|
if (! $this->application->settings->use_build_secrets || ! $this->deploymentPassesBuildtimeVariables()) {
|
|
return '';
|
|
}
|
|
|
|
// Generate env variables if not already done
|
|
// This populates $this->env_args with both user-defined and COOLIFY_* variables
|
|
if (! $this->env_args || $this->env_args->isEmpty()) {
|
|
$this->generate_env_variables();
|
|
}
|
|
|
|
// Invalid names stay out of the helper container: a build fails on them before it starts,
|
|
// and restarts or deployments that reuse an image do not need them.
|
|
$variables = $this->env_args->filter(fn ($value, $key): bool => ValidationPatterns::isValidEnvironmentVariableKey((string) $key));
|
|
|
|
if ($this->build_pack === 'railpack') {
|
|
$variables = $this->without_reserved_docker_client_variables($variables);
|
|
}
|
|
|
|
if ($variables->isEmpty()) {
|
|
return '';
|
|
}
|
|
|
|
$vars_array = $this->withRawBuildSecretValues($variables)->map(fn ($value, $key): array => [
|
|
'key' => $this->validatedBuildtimeEnvironmentVariableKey((string) $key, 'the build secret environment'),
|
|
'value' => $value,
|
|
]);
|
|
$this->helperBuildSecretEnvironmentKeys = $vars_array->keys()->map(fn ($key): string => (string) $key)->values()->all();
|
|
|
|
return generateDockerEnvFlags($vars_array);
|
|
}
|
|
|
|
/**
|
|
* The build-time values are escaped or quoted for .env files and shell build arguments, where the
|
|
* shell removes the escapes again. A build secret gets its value from the helper container as it
|
|
* is, so it gets the raw value of each variable instead.
|
|
*
|
|
* @param Collection<string, mixed> $variables
|
|
* @return Collection<string, mixed>
|
|
*/
|
|
private function withRawBuildSecretValues(Collection $variables): Collection
|
|
{
|
|
$environmentVariables = $this->pull_request_id === 0
|
|
? $this->application->environment_variables()
|
|
: $this->application->environment_variables_preview()->whereNotIn('id', $this->previewVariableIdsWithProductionValues());
|
|
$rawValues = $environmentVariables
|
|
->where('is_buildtime', true)
|
|
->whereIn('key', $variables->keys()->all())
|
|
->get()
|
|
->mapWithKeys(fn (EnvironmentVariable $env): array => [$env->key => $this->resolve_environment_variable_raw($env)]);
|
|
|
|
return $variables->map(fn ($value, $key) => $rawValues->get($key, $value));
|
|
}
|
|
|
|
/**
|
|
* Docker Compose prefers the process environment over `--env-file`. A command in the helper container
|
|
* that pulls or starts services must not see the build-time values that the container holds for build
|
|
* secrets, so the services get the values of the runtime .env file.
|
|
*/
|
|
private function withoutHelperBuildSecretEnvironment(string $command): string
|
|
{
|
|
if ($this->helperBuildSecretEnvironmentKeys === []) {
|
|
return $command;
|
|
}
|
|
|
|
return 'unset '.implode(' ', $this->helperBuildSecretEnvironmentKeys)."; {$command}";
|
|
}
|
|
|
|
private function generate_build_secrets(Collection $variables)
|
|
{
|
|
if ($variables->isEmpty()) {
|
|
$this->build_secrets = '';
|
|
|
|
return;
|
|
}
|
|
|
|
$this->build_secrets = $variables
|
|
->map(function ($value, $key) {
|
|
$key = $this->validatedBuildtimeEnvironmentVariableKey((string) $key, 'the build secret environment');
|
|
|
|
return '--secret '.escapeshellarg("id={$key},env={$key}");
|
|
})
|
|
->implode(' ');
|
|
|
|
// Docker ignores secret values for the build cache, so the hash goes in as a build arg
|
|
$this->build_secrets .= ' --build-arg '.escapeshellarg('COOLIFY_BUILD_SECRETS_HASH='.$this->generate_secrets_hash($this->withRawBuildSecretValues($variables)));
|
|
}
|
|
|
|
private function generate_secrets_hash($variables)
|
|
{
|
|
if (! $this->secrets_hash_key) {
|
|
// Use APP_KEY as deterministic hash key to preserve Docker build cache
|
|
// Random keys would change every deployment, breaking cache even when secrets haven't changed
|
|
$this->secrets_hash_key = config('app.key');
|
|
}
|
|
|
|
if ($variables instanceof Collection) {
|
|
$secrets_string = $variables
|
|
->mapWithKeys(function ($value, $key) {
|
|
return [$key => $value];
|
|
})
|
|
->sortKeys()
|
|
->map(function ($value, $key) {
|
|
return "{$key}={$value}";
|
|
})
|
|
->implode('|');
|
|
} else {
|
|
$secrets_string = $variables
|
|
->map(function ($env) {
|
|
return "{$env->key}={$this->resolve_environment_variable($env)}";
|
|
})
|
|
->sort()
|
|
->implode('|');
|
|
}
|
|
|
|
return hash_hmac('sha256', $secrets_string, $this->secrets_hash_key);
|
|
}
|
|
|
|
/**
|
|
* Returns the last line of each FROM instruction, so stage ARGs can go directly after it.
|
|
*/
|
|
protected function findFromInstructionLines($dockerfile): array
|
|
{
|
|
return collect($this->dockerfileInstructions($dockerfile))
|
|
->where('keyword', 'FROM')
|
|
->pluck('end')
|
|
->all();
|
|
}
|
|
|
|
/**
|
|
* Splits a Dockerfile into instructions with their first and last line. Comments, continuation
|
|
* lines, and heredoc bodies are part of an instruction, so text such as "from app import x"
|
|
* in a heredoc is not read as an instruction.
|
|
*
|
|
* @return list<array{keyword: string, start: int, end: int}>
|
|
*/
|
|
protected function dockerfileInstructions(iterable $dockerfile): array
|
|
{
|
|
$escape = '\\';
|
|
$instructions = [];
|
|
$continues = false;
|
|
$heredocTerminators = [];
|
|
|
|
foreach ($dockerfile as $index => $line) {
|
|
$trimmed = trim($line);
|
|
|
|
if ($heredocTerminators !== []) {
|
|
$instructions[array_key_last($instructions)]['end'] = $index;
|
|
if ($trimmed === $heredocTerminators[0]) {
|
|
array_shift($heredocTerminators);
|
|
}
|
|
|
|
continue;
|
|
}
|
|
|
|
if ($trimmed === '' || str_starts_with($trimmed, '#')) {
|
|
if ($instructions === [] && preg_match('/^#\s*escape\s*=\s*([\\\\`])\s*$/i', $trimmed, $matches) === 1) {
|
|
$escape = $matches[1];
|
|
}
|
|
|
|
continue;
|
|
}
|
|
|
|
if ($continues) {
|
|
$instructions[array_key_last($instructions)]['end'] = $index;
|
|
} else {
|
|
$instructions[] = ['keyword' => strtoupper(strtok($trimmed, " \t")), 'start' => $index, 'end' => $index];
|
|
}
|
|
$continues = str_ends_with($trimmed, $escape);
|
|
|
|
$keyword = $instructions[array_key_last($instructions)]['keyword'];
|
|
if (in_array($keyword, ['RUN', 'COPY', 'ADD'], true)
|
|
&& preg_match_all('/(?<!<)<<-?(["\']?)([A-Za-z_][A-Za-z0-9_]*)\1/', $line, $matches) > 0) {
|
|
$heredocTerminators = $matches[2];
|
|
}
|
|
}
|
|
|
|
return $instructions;
|
|
}
|
|
|
|
private function add_build_env_variables_to_dockerfile()
|
|
{
|
|
if ($this->dockerSecretsSupported) {
|
|
// We dont need to add ARG declarations when using Docker build secrets, as variables are passed with --secret flag
|
|
return;
|
|
}
|
|
|
|
// Skip ARG injection if disabled by user - preserves Docker build cache
|
|
if ($this->application->settings->inject_build_args_to_dockerfile === false) {
|
|
$this->application_deployment_queue->addLogEntry('Skipping Dockerfile ARG injection (disabled in settings).', hidden: true);
|
|
|
|
return;
|
|
}
|
|
|
|
$this->execute_remote_command([
|
|
executeInDocker($this->deployment_uuid, "cat {$this->workdir}{$this->dockerfile_location}"),
|
|
'hidden' => true,
|
|
'save' => 'dockerfile',
|
|
'ignore_errors' => true,
|
|
]);
|
|
$dockerfile = collect(str($this->saved_outputs->get('dockerfile'))->trim()->explode("\n"));
|
|
|
|
// Find all FROM instruction positions
|
|
$fromLines = $this->findFromInstructionLines($dockerfile);
|
|
|
|
// If no FROM instructions found, skip ARG insertion
|
|
if (empty($fromLines)) {
|
|
return;
|
|
}
|
|
|
|
// Collect all ARG statements to insert
|
|
$argsToInsert = collect();
|
|
|
|
if ($this->pull_request_id === 0) {
|
|
// Only add environment variables that are available during build
|
|
$envs = $this->application->environment_variables()
|
|
->withoutBuildpackControlVariables()
|
|
->where('is_buildtime', true)
|
|
->get();
|
|
$envs = $this->withoutLegacyEnvironmentVariableKeys($envs);
|
|
foreach ($envs as $env) {
|
|
$key = $this->validatedBuildtimeEnvironmentVariableKey((string) $env->key, 'the generated Dockerfile');
|
|
$argsToInsert->push("ARG {$key}");
|
|
}
|
|
// Add Coolify variables as ARGs
|
|
if ($this->coolify_variables) {
|
|
$coolify_vars = collect(explode(' ', trim($this->coolify_variables)))
|
|
->filter()
|
|
->map(function ($var) {
|
|
return 'ARG '.$this->shellAssignmentForDockerfileArg($var);
|
|
});
|
|
$argsToInsert = $argsToInsert->merge($coolify_vars);
|
|
}
|
|
} else {
|
|
// Only add preview environment variables that are available during build
|
|
$envs = $this->application->environment_variables_preview()
|
|
->whereNotIn('id', $this->previewVariableIdsWithProductionValues())
|
|
->withoutBuildpackControlVariables()
|
|
->where('is_buildtime', true)
|
|
->get();
|
|
$envs = $this->withoutLegacyEnvironmentVariableKeys($envs);
|
|
foreach ($envs as $env) {
|
|
$key = $this->validatedBuildtimeEnvironmentVariableKey((string) $env->key, 'the generated Dockerfile');
|
|
$argsToInsert->push("ARG {$key}");
|
|
}
|
|
// Add Coolify variables as ARGs
|
|
if ($this->coolify_variables) {
|
|
$coolify_vars = collect(explode(' ', trim($this->coolify_variables)))
|
|
->filter()
|
|
->map(function ($var) {
|
|
return 'ARG '.$this->shellAssignmentForDockerfileArg($var);
|
|
});
|
|
$argsToInsert = $argsToInsert->merge($coolify_vars);
|
|
}
|
|
}
|
|
|
|
if ($argsToInsert->isNotEmpty()) {
|
|
$environmentVariables = $envs->mapWithKeys(function ($environmentVariable) {
|
|
return [$environmentVariable->key => escapeBashEnvValue($this->resolve_environment_variable_raw($environmentVariable))];
|
|
});
|
|
$secretsHash = $this->generate_secrets_hash($environmentVariables);
|
|
$argsToInsert->push("ARG COOLIFY_BUILD_SECRETS_HASH={$secretsHash}");
|
|
}
|
|
|
|
// Development logging to show what ARGs are being injected
|
|
if (isDev()) {
|
|
$this->application_deployment_queue->addLogEntry('[DEBUG] ========================================');
|
|
$this->application_deployment_queue->addLogEntry('[DEBUG] Dockerfile ARG Injection');
|
|
$this->application_deployment_queue->addLogEntry('[DEBUG] ========================================');
|
|
$this->application_deployment_queue->addLogEntry('[DEBUG] ARGs to inject: '.$argsToInsert->count());
|
|
foreach ($argsToInsert as $arg) {
|
|
// Only show ARG key, not the value (for security)
|
|
$argKey = str($arg)->after('ARG ')->before('=')->toString();
|
|
$this->application_deployment_queue->addLogEntry("[DEBUG] - {$argKey}");
|
|
}
|
|
}
|
|
|
|
// Insert ARGs after each FROM instruction (in reverse order to maintain correct line numbers)
|
|
if ($argsToInsert->isNotEmpty()) {
|
|
foreach (array_reverse($fromLines) as $fromLineIndex) {
|
|
// Insert all ARGs after this FROM instruction
|
|
foreach ($argsToInsert->reverse() as $arg) {
|
|
$dockerfile->splice($fromLineIndex + 1, 0, [$arg]);
|
|
}
|
|
}
|
|
}
|
|
|
|
$dockerfile_base64 = base64_encode($dockerfile->implode("\n"));
|
|
$this->application_deployment_queue->addLogEntry('Final Dockerfile:', type: 'info', hidden: true);
|
|
$this->execute_remote_command(
|
|
[
|
|
executeInDocker($this->deployment_uuid, "echo '{$dockerfile_base64}' | base64 -d | tee {$this->workdir}{$this->dockerfile_location} > /dev/null"),
|
|
'hidden' => true,
|
|
'skip_command_log' => true,
|
|
]);
|
|
}
|
|
|
|
private function modify_dockerfile_for_secrets($dockerfile_path)
|
|
{
|
|
// Only process if build secrets are enabled and we have secrets to mount
|
|
if (empty($this->build_secrets)) {
|
|
return;
|
|
}
|
|
|
|
// Read the Dockerfile
|
|
$this->execute_remote_command([
|
|
executeInDocker($this->deployment_uuid, "cat {$dockerfile_path}"),
|
|
'hidden' => true,
|
|
'save' => 'dockerfile_content',
|
|
]);
|
|
|
|
$dockerfile = str($this->saved_outputs->get('dockerfile_content'))->trim()->explode("\n");
|
|
|
|
// Add BuildKit syntax directive if not present
|
|
if (! str_starts_with($dockerfile->first(), '# syntax=')) {
|
|
$dockerfile->prepend('# syntax=docker/dockerfile:1');
|
|
}
|
|
|
|
// Generate env variables if not already done
|
|
// This populates $this->env_args with both user-defined and COOLIFY_* variables
|
|
if (! $this->env_args || $this->env_args->isEmpty()) {
|
|
$this->generate_env_variables();
|
|
}
|
|
|
|
$variables = $this->application->build_pack === 'nixpacks'
|
|
? collect($this->nixpacks_plan_json->get('variables'))
|
|
: $this->env_args;
|
|
if ($variables->isEmpty()) {
|
|
return;
|
|
}
|
|
|
|
// Generate mount strings for all secrets
|
|
$mountStrings = $variables->map(function ($value, $key) {
|
|
$key = $this->validatedBuildtimeEnvironmentVariableKey((string) $key, 'the generated Dockerfile');
|
|
|
|
return "--mount=type=secret,id={$key},env={$key}";
|
|
})->implode(' ');
|
|
|
|
$instructions = collect($this->dockerfileInstructions($dockerfile));
|
|
$runLines = $instructions->where('keyword', 'RUN')->pluck('start')->all();
|
|
$fromEndLines = $instructions->where('keyword', 'FROM')->pluck('end')->all();
|
|
|
|
$modified = false;
|
|
$dockerfile = $dockerfile->flatMap(function ($line, $index) use ($mountStrings, $runLines, $fromEndLines, &$modified) {
|
|
// Add mount strings to RUN commands that do not have secret mounts yet
|
|
if (in_array($index, $runLines, true) && ! str_contains($line, '--mount=type=secret')) {
|
|
$originalCommand = trim(substr(ltrim($line), 3));
|
|
$line = "RUN {$mountStrings} {$originalCommand}";
|
|
$modified = true;
|
|
}
|
|
|
|
// A RUN step reads every ARG of its stage, so a changed hash rebuilds it
|
|
if (in_array($index, $fromEndLines, true)) {
|
|
$modified = true;
|
|
|
|
return [$line, 'ARG COOLIFY_BUILD_SECRETS_HASH'];
|
|
}
|
|
|
|
return [$line];
|
|
});
|
|
|
|
if ($modified) {
|
|
// Write the modified Dockerfile back
|
|
$dockerfile_base64 = base64_encode($dockerfile->implode("\n"));
|
|
$this->execute_remote_command([
|
|
executeInDocker($this->deployment_uuid, "echo '{$dockerfile_base64}' | base64 -d | tee {$dockerfile_path} > /dev/null"),
|
|
'hidden' => true,
|
|
]);
|
|
}
|
|
}
|
|
|
|
private function modify_dockerfiles_for_compose($composeFile)
|
|
{
|
|
if ($this->application->build_pack !== 'dockercompose') {
|
|
return;
|
|
}
|
|
|
|
// Skip ARG injection if disabled by user - preserves Docker build cache
|
|
if ($this->application->settings->inject_build_args_to_dockerfile === false) {
|
|
$this->application_deployment_queue->addLogEntry('Skipping Docker Compose Dockerfile ARG injection (disabled in settings).', hidden: true);
|
|
|
|
return;
|
|
}
|
|
|
|
// Generate env variables if not already done
|
|
// This populates $this->env_args with both user-defined and COOLIFY_* variables
|
|
if (! $this->env_args || $this->env_args->isEmpty()) {
|
|
$this->generate_env_variables();
|
|
}
|
|
|
|
$variables = $this->env_args;
|
|
|
|
if ($variables->isEmpty()) {
|
|
$this->application_deployment_queue->addLogEntry('No build-time variables to add to Dockerfiles.');
|
|
|
|
return;
|
|
}
|
|
|
|
$services = data_get($composeFile, 'services', []);
|
|
|
|
foreach ($services as $serviceName => $service) {
|
|
if (! isset($service['build'])) {
|
|
continue;
|
|
}
|
|
|
|
$dockerfilePath = $this->resolveComposeDockerfilePath($service['build']);
|
|
if ($dockerfilePath === null) {
|
|
$this->application_deployment_queue->addLogEntry($this->composeArgInjectionSkippedMessage((string) $serviceName, $service['build'], $variables));
|
|
|
|
continue;
|
|
}
|
|
// Compose resolves relative paths from the project directory (the workdir).
|
|
$fullDockerfilePath = escapeshellarg(str_starts_with($dockerfilePath, '/') ? $dockerfilePath : "{$this->workdir}/{$dockerfilePath}");
|
|
|
|
// BusyBox realpath in the helper image accepts no options; a missing file prints nothing.
|
|
$this->execute_remote_command([
|
|
executeInDocker($this->deployment_uuid, "resolved_path=$(realpath {$fullDockerfilePath} 2>/dev/null) && test -f \"\$resolved_path\" && printf '%s' \"\$resolved_path\" || true"),
|
|
'hidden' => true,
|
|
'save' => 'dockerfile_check_'.$serviceName,
|
|
]);
|
|
|
|
// A monorepo context may leave the base directory, but never the cloned repository.
|
|
$resolvedDockerfilePath = str($this->saved_outputs->get('dockerfile_check_'.$serviceName))->trim()->toString();
|
|
if (! str_starts_with($resolvedDockerfilePath, "{$this->basedir}/")) {
|
|
$this->application_deployment_queue->addLogEntry("Dockerfile not found for service {$serviceName} at {$dockerfilePath}, skipping ARG injection.");
|
|
|
|
continue;
|
|
}
|
|
$fullDockerfilePath = escapeshellarg($resolvedDockerfilePath);
|
|
|
|
$this->execute_remote_command([
|
|
executeInDocker($this->deployment_uuid, "cat {$fullDockerfilePath}"),
|
|
'hidden' => true,
|
|
'save' => 'dockerfile_content_'.$serviceName,
|
|
]);
|
|
|
|
$dockerfileContent = $this->saved_outputs->get('dockerfile_content_'.$serviceName);
|
|
if (! $dockerfileContent) {
|
|
continue;
|
|
}
|
|
|
|
$dockerfile_lines = collect(str($dockerfileContent)->trim()->explode("\n"));
|
|
|
|
$fromIndices = $this->findFromInstructionLines($dockerfile_lines);
|
|
|
|
if (empty($fromIndices)) {
|
|
$this->application_deployment_queue->addLogEntry("No FROM instruction found in Dockerfile for service {$serviceName}, skipping.");
|
|
|
|
continue;
|
|
}
|
|
|
|
$isMultiStage = count($fromIndices) > 1;
|
|
|
|
$argsToAdd = collect([]);
|
|
foreach ($variables as $key => $value) {
|
|
$key = $this->validatedBuildtimeEnvironmentVariableKey((string) $key, 'the generated Dockerfile');
|
|
$argsToAdd->push("ARG {$key}");
|
|
}
|
|
|
|
if ($argsToAdd->isEmpty()) {
|
|
$this->application_deployment_queue->addLogEntry("Service {$serviceName}: No build-time variables to add.");
|
|
|
|
continue;
|
|
}
|
|
|
|
// Development logging to show what ARGs are being injected for Docker Compose
|
|
if (isDev()) {
|
|
$this->application_deployment_queue->addLogEntry('[DEBUG] ========================================');
|
|
$this->application_deployment_queue->addLogEntry("[DEBUG] Docker Compose ARG Injection - Service: {$serviceName}");
|
|
$this->application_deployment_queue->addLogEntry('[DEBUG] ========================================');
|
|
$this->application_deployment_queue->addLogEntry('[DEBUG] ARGs to inject: '.$argsToAdd->count());
|
|
foreach ($argsToAdd as $arg) {
|
|
$argKey = str($arg)->after('ARG ')->toString();
|
|
$this->application_deployment_queue->addLogEntry("[DEBUG] - {$argKey}");
|
|
}
|
|
}
|
|
|
|
$totalAdded = 0;
|
|
$offset = 0;
|
|
|
|
foreach ($fromIndices as $stageIndex => $fromIndex) {
|
|
$adjustedIndex = $fromIndex + $offset;
|
|
|
|
$stageStart = $adjustedIndex + 1;
|
|
$stageEnd = isset($fromIndices[$stageIndex + 1])
|
|
? $fromIndices[$stageIndex + 1] + $offset
|
|
: $dockerfile_lines->count();
|
|
|
|
$existingStageArgs = collect([]);
|
|
for ($i = $stageStart; $i < $stageEnd; $i++) {
|
|
$line = $dockerfile_lines->get($i);
|
|
if (! $line || ! str($line)->trim()->startsWith('ARG')) {
|
|
break;
|
|
}
|
|
$parts = explode(' ', trim($line), 2);
|
|
if (count($parts) >= 2) {
|
|
$argPart = $parts[1];
|
|
$keyValue = explode('=', $argPart, 2);
|
|
$existingStageArgs->push($keyValue[0]);
|
|
}
|
|
}
|
|
|
|
$stageArgsToAdd = $argsToAdd->filter(function ($arg) use ($existingStageArgs) {
|
|
$key = str($arg)->after('ARG ')->trim()->toString();
|
|
|
|
return ! $existingStageArgs->contains($key);
|
|
});
|
|
|
|
if ($stageArgsToAdd->isNotEmpty()) {
|
|
$dockerfile_lines->splice($adjustedIndex + 1, 0, $stageArgsToAdd->toArray());
|
|
$totalAdded += $stageArgsToAdd->count();
|
|
$offset += $stageArgsToAdd->count();
|
|
}
|
|
}
|
|
|
|
if ($totalAdded > 0) {
|
|
$dockerfile_base64 = base64_encode($dockerfile_lines->implode("\n"));
|
|
$this->execute_remote_command([
|
|
executeInDocker($this->deployment_uuid, "echo '{$dockerfile_base64}' | base64 -d | tee {$fullDockerfilePath} > /dev/null"),
|
|
'hidden' => true,
|
|
]);
|
|
|
|
$stageInfo = $isMultiStage ? ' (multi-stage build, added to '.count($fromIndices).' stages)' : '';
|
|
$this->application_deployment_queue->addLogEntry("Added {$totalAdded} ARG declarations to Dockerfile for service {$serviceName}{$stageInfo}.");
|
|
} else {
|
|
$this->application_deployment_queue->addLogEntry("Service {$serviceName}: All required ARG declarations already exist.");
|
|
}
|
|
|
|
if ($this->dockerSecretsSupported && ! empty($this->build_secrets)) {
|
|
$this->modify_dockerfile_for_secrets($fullDockerfilePath);
|
|
$this->application_deployment_queue->addLogEntry("Modified Dockerfile for service {$serviceName} to use build secrets.");
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Returns the Dockerfile path of a Compose build as written (relative to the project directory,
|
|
* or absolute), or null when Coolify cannot inspect it locally: a remote git context, a path that
|
|
* Compose fills in from variables, or an inline Dockerfile. The path is untrusted: the caller
|
|
* quotes it and only uses the resolved file when it is inside the cloned repository.
|
|
*/
|
|
private function resolveComposeDockerfilePath(mixed $build): ?string
|
|
{
|
|
if (! is_string($build) && ! is_array($build)) {
|
|
return null;
|
|
}
|
|
|
|
$context = is_string($build) ? $build : data_get($build, 'context', '.');
|
|
$dockerfile = is_array($build) ? data_get($build, 'dockerfile', 'Dockerfile') : 'Dockerfile';
|
|
|
|
if (! is_string($context) || ! is_string($dockerfile) || $context === '' || (is_array($build) && array_key_exists('dockerfile_inline', $build))) {
|
|
return null;
|
|
}
|
|
if (str_contains($context.$dockerfile, '$') || preg_match('~^[a-z][a-z0-9+.-]*://|^git@~i', $context) === 1) {
|
|
return null;
|
|
}
|
|
|
|
return str_starts_with($dockerfile, '/') ? $dockerfile : rtrim($context, '/').'/'.$dockerfile;
|
|
}
|
|
|
|
/**
|
|
* Tells the user why Coolify cannot add ARG lines to a Compose service Dockerfile, and which
|
|
* build-time variables to declare there. Only valid variable names are shown, never values.
|
|
*
|
|
* @param Collection<string, mixed> $variables
|
|
*/
|
|
private function composeArgInjectionSkippedMessage(string $serviceName, mixed $build, Collection $variables): string
|
|
{
|
|
$context = is_string($build) ? $build : data_get($build, 'context');
|
|
$dockerfile = is_array($build) ? data_get($build, 'dockerfile') : null;
|
|
|
|
$reason = match (true) {
|
|
is_array($build) && array_key_exists('dockerfile_inline', $build) => 'the service uses an inline Dockerfile (dockerfile_inline)',
|
|
is_string($context) && preg_match('~^[a-z][a-z0-9+.-]*://|^git@~i', $context) === 1 => 'the build context is a remote Git URL',
|
|
(is_string($context) && str_contains($context, '$')) || (is_string($dockerfile) && str_contains($dockerfile, '$')) => 'the build context or Dockerfile path uses variables',
|
|
default => 'Coolify cannot read the build definition',
|
|
};
|
|
|
|
$names = $variables->keys()
|
|
->map(fn ($key) => (string) $key)
|
|
->filter(fn (string $key) => ValidationPatterns::isValidEnvironmentVariableKey($key))
|
|
->sortBy(fn (string $key) => str_starts_with($key, 'COOLIFY_') ? 1 : 0)
|
|
->values();
|
|
|
|
$message = "Skipping ARG injection for service {$serviceName}: {$reason}. Docker ignores build-time variables that the Dockerfile does not declare, so add 'ARG <NAME>' lines to the Dockerfile for the ones you need";
|
|
if ($names->isEmpty()) {
|
|
return "{$message}.";
|
|
}
|
|
|
|
$listedNames = $names->take(10)->implode(', ');
|
|
$moreCount = $names->count() - 10;
|
|
|
|
return $moreCount > 0 ? "{$message}: {$listedNames} and {$moreCount} more." : "{$message}: {$listedNames}.";
|
|
}
|
|
|
|
private function add_build_secrets_to_compose($composeFile)
|
|
{
|
|
// Generate env variables if not already done
|
|
// This populates $this->env_args with both user-defined and COOLIFY_* variables
|
|
if (! $this->env_args || $this->env_args->isEmpty()) {
|
|
$this->generate_env_variables();
|
|
}
|
|
|
|
$variables = $this->env_args;
|
|
|
|
if ($variables->isEmpty()) {
|
|
return $composeFile;
|
|
}
|
|
|
|
$secrets = [];
|
|
foreach ($variables as $key => $value) {
|
|
$key = $this->validatedBuildtimeEnvironmentVariableKey((string) $key, 'the Compose build secret environment');
|
|
$secrets[$key] = [
|
|
'environment' => $key,
|
|
];
|
|
}
|
|
$secretsHash = $this->generate_secrets_hash($this->withRawBuildSecretValues($variables));
|
|
|
|
$services = data_get($composeFile, 'services', []);
|
|
foreach ($services as $serviceName => &$service) {
|
|
if (isset($service['build'])) {
|
|
if (is_string($service['build'])) {
|
|
$service['build'] = [
|
|
'context' => $service['build'],
|
|
];
|
|
}
|
|
if (! isset($service['build']['secrets'])) {
|
|
$service['build']['secrets'] = [];
|
|
}
|
|
foreach (array_keys($secrets) as $key) {
|
|
if (! in_array($key, $service['build']['secrets'])) {
|
|
$service['build']['secrets'][] = $key;
|
|
}
|
|
}
|
|
|
|
// Docker ignores secret values for the build cache, so the hash goes in as a build arg
|
|
$args = $service['build']['args'] ?? [];
|
|
if ($args instanceof Collection) {
|
|
$args = $args->toArray();
|
|
}
|
|
if ($args !== [] && array_is_list($args)) {
|
|
$args[] = "COOLIFY_BUILD_SECRETS_HASH={$secretsHash}";
|
|
} else {
|
|
$args['COOLIFY_BUILD_SECRETS_HASH'] = $secretsHash;
|
|
}
|
|
$service['build']['args'] = $args;
|
|
}
|
|
}
|
|
|
|
$composeFile['services'] = $services;
|
|
$existingSecrets = data_get($composeFile, 'secrets', []);
|
|
if ($existingSecrets instanceof Collection) {
|
|
$existingSecrets = $existingSecrets->toArray();
|
|
}
|
|
$composeFile['secrets'] = array_replace($existingSecrets, $secrets);
|
|
|
|
$this->application_deployment_queue->addLogEntry('Added build secrets configuration to docker-compose file (using environment variables).');
|
|
|
|
return $composeFile;
|
|
}
|
|
|
|
private function validatePathField(string $value, string $fieldName): string
|
|
{
|
|
if (! preg_match(ValidationPatterns::FILE_PATH_PATTERN, $value)) {
|
|
throw new \RuntimeException("Invalid {$fieldName}: contains forbidden characters.");
|
|
}
|
|
if (str_contains($value, '..')) {
|
|
throw new \RuntimeException("Invalid {$fieldName}: path traversal detected.");
|
|
}
|
|
|
|
return $value;
|
|
}
|
|
|
|
private function validateShellSafeCommand(string $value, string $fieldName): string
|
|
{
|
|
if (! preg_match(ValidationPatterns::SHELL_SAFE_COMMAND_PATTERN, $value)) {
|
|
throw new \RuntimeException("Invalid {$fieldName}: contains forbidden shell characters.");
|
|
}
|
|
|
|
return $value;
|
|
}
|
|
|
|
private function validateContainerName(string $value): string
|
|
{
|
|
if (! preg_match(ValidationPatterns::CONTAINER_NAME_PATTERN, $value)) {
|
|
throw new \RuntimeException('Invalid container name: contains forbidden characters.');
|
|
}
|
|
|
|
return $value;
|
|
}
|
|
|
|
/**
|
|
* Resolve which container to execute a deployment command in.
|
|
*
|
|
* For single-container apps, returns the sole container.
|
|
* For multi-container apps, matches by the user-specified container name.
|
|
* If no container name is specified for multi-container apps, logs available containers and returns null.
|
|
*/
|
|
private function resolveCommandContainer(Collection $containers, ?string $specifiedContainerName, string $commandType): ?array
|
|
{
|
|
if ($containers->count() === 0) {
|
|
return null;
|
|
}
|
|
|
|
if ($containers->count() === 1) {
|
|
return $containers->first();
|
|
}
|
|
|
|
// Multi-container: require a container name to be specified
|
|
if (empty($specifiedContainerName)) {
|
|
$available = $containers->map(fn ($c) => data_get($c, 'Names'))->implode(', ');
|
|
$this->application_deployment_queue->addLogEntry(
|
|
"{$commandType} command: Multiple containers found but no container name specified. Available: {$available}"
|
|
);
|
|
|
|
return null;
|
|
}
|
|
|
|
// Multi-container: match by specified name prefix
|
|
$prefix = $specifiedContainerName.'-'.$this->application->uuid;
|
|
foreach ($containers as $container) {
|
|
$containerName = data_get($container, 'Names');
|
|
if (str_starts_with($containerName, $prefix)) {
|
|
return $container;
|
|
}
|
|
}
|
|
|
|
// No match found — log available containers to help the user debug
|
|
$available = $containers->map(fn ($c) => data_get($c, 'Names'))->implode(', ');
|
|
$this->application_deployment_queue->addLogEntry(
|
|
"{$commandType} command: Container '{$specifiedContainerName}' not found. Available: {$available}"
|
|
);
|
|
|
|
return null;
|
|
}
|
|
|
|
private function run_pre_deployment_command()
|
|
{
|
|
if (empty($this->application->pre_deployment_command)) {
|
|
return;
|
|
}
|
|
$containers = getCurrentApplicationContainerStatus($this->server, $this->application, $this->pull_request_id);
|
|
if ($containers->count() == 0) {
|
|
$this->application_deployment_queue->addLogEntry('Pre-deployment command: No running containers found. Skipping.');
|
|
|
|
return;
|
|
}
|
|
$this->application_deployment_queue->addLogEntry('Executing pre-deployment command (see debug log for output/errors).');
|
|
|
|
$container = $this->resolveCommandContainer($containers, $this->application->pre_deployment_command_container, 'Pre-deployment');
|
|
if ($container === null) {
|
|
throw new DeploymentException('Pre-deployment command: Could not find a valid container. Is the container name correct?');
|
|
}
|
|
|
|
$containerName = data_get($container, 'Names');
|
|
if ($containerName) {
|
|
$this->validateContainerName($containerName);
|
|
}
|
|
// Security: pre_deployment_command is intentionally treated as arbitrary shell input.
|
|
// Users (team members with deployment access) need full shell flexibility to run commands
|
|
// like "php artisan migrate", "npm run build", etc. inside their own application containers.
|
|
// The trust boundary is at the application/team ownership level — only authenticated team
|
|
// members can set these commands, and execution is scoped to the application's own container.
|
|
// The single-quote escaping here prevents breaking out of the sh -c wrapper, but does not
|
|
// restrict the command itself. Container names are validated separately via validateContainerName().
|
|
// Newlines are normalized to spaces to prevent injection via SSH heredoc transport
|
|
// (matches the pattern used for health_check_command at line ~2824).
|
|
$preCommand = str_replace(["\r\n", "\r", "\n"], ' ', $this->application->pre_deployment_command);
|
|
$cmd = "sh -c '".str_replace("'", "'\''", $preCommand)."'";
|
|
$exec = "docker exec {$containerName} {$cmd}";
|
|
$this->execute_remote_command(
|
|
[
|
|
'command' => $exec,
|
|
'hidden' => true,
|
|
],
|
|
);
|
|
}
|
|
|
|
private function run_post_deployment_command()
|
|
{
|
|
if (empty($this->application->post_deployment_command)) {
|
|
return;
|
|
}
|
|
$this->application_deployment_queue->addLogEntry('----------------------------------------');
|
|
$this->application_deployment_queue->addLogEntry('Executing post-deployment command (see debug log for output).');
|
|
|
|
$containers = getCurrentApplicationContainerStatus($this->server, $this->application, $this->pull_request_id);
|
|
if ($containers->count() == 0) {
|
|
$this->application_deployment_queue->addLogEntry('Post-deployment command: No running containers found. Skipping.');
|
|
|
|
return;
|
|
}
|
|
|
|
$container = $this->resolveCommandContainer($containers, $this->application->post_deployment_command_container, 'Post-deployment');
|
|
if ($container === null) {
|
|
throw new DeploymentException('Post-deployment command: Could not find a valid container. Is the container name correct?');
|
|
}
|
|
|
|
$containerName = data_get($container, 'Names');
|
|
if ($containerName) {
|
|
$this->validateContainerName($containerName);
|
|
}
|
|
// Security: post_deployment_command is intentionally treated as arbitrary shell input.
|
|
// See the equivalent comment in run_pre_deployment_command() for the full security rationale.
|
|
// Newlines are normalized to spaces to prevent injection via SSH heredoc transport.
|
|
$postCommand = str_replace(["\r\n", "\r", "\n"], ' ', $this->application->post_deployment_command);
|
|
$cmd = "sh -c '".str_replace("'", "'\''", $postCommand)."'";
|
|
$exec = "docker exec {$containerName} {$cmd}";
|
|
try {
|
|
$this->execute_remote_command(
|
|
[
|
|
'command' => $exec,
|
|
'hidden' => true,
|
|
'save' => 'post-deployment-command-output',
|
|
],
|
|
);
|
|
} catch (Exception $e) {
|
|
$post_deployment_command_output = $this->saved_outputs->get('post-deployment-command-output');
|
|
if ($post_deployment_command_output) {
|
|
$this->application_deployment_queue->addLogEntry('Post-deployment command failed.');
|
|
$this->application_deployment_queue->addLogEntry($post_deployment_command_output, 'stderr');
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Check if the deployment was cancelled and abort if it was
|
|
*/
|
|
private function checkForCancellation(): void
|
|
{
|
|
$this->application_deployment_queue->refresh();
|
|
if ($this->application_deployment_queue->status === ApplicationDeploymentStatus::CANCELLED_BY_USER->value) {
|
|
$this->application_deployment_queue->addLogEntry('Deployment cancelled by user, stopping execution.');
|
|
throw new DeploymentException('Deployment cancelled by user', 69420);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Marks that the deployment starts to change containers. From here on, a newer webhook deployment
|
|
* does not cancel it, so the application is never left half deployed. The update is conditional,
|
|
* so it cannot race with that cancel: exactly one of them wins.
|
|
*/
|
|
private function startDeployPhase(): void
|
|
{
|
|
ApplicationDeploymentQueue::query()
|
|
->whereKey($this->application_deployment_queue->getKey())
|
|
->where('status', ApplicationDeploymentStatus::IN_PROGRESS->value)
|
|
->whereNull('deploy_started_at')
|
|
->update(['deploy_started_at' => Carbon::now()]);
|
|
$this->checkForCancellation();
|
|
}
|
|
|
|
/**
|
|
* Transition deployment to a new status with proper validation and side effects.
|
|
* This is the single source of truth for status transitions.
|
|
*/
|
|
private function transitionToStatus(ApplicationDeploymentStatus $status): void
|
|
{
|
|
if ($this->isInTerminalState()) {
|
|
return;
|
|
}
|
|
|
|
$this->updateDeploymentStatus($status);
|
|
|
|
// The status is stored. Side effect failures are logged so they cannot flip a finished
|
|
// deployment to failed or stop the next queued deployment from starting.
|
|
try {
|
|
$this->handleStatusTransition($status);
|
|
} catch (Throwable $e) {
|
|
$this->logStatusTransitionSideEffectFailure("Post-{$status->value} actions failed", $e);
|
|
}
|
|
|
|
try {
|
|
queue_next_deployment($this->application, $this->application_deployment_queue->server_id);
|
|
} catch (Throwable $e) {
|
|
$this->logStatusTransitionSideEffectFailure('Starting the next queued deployment failed', $e);
|
|
}
|
|
}
|
|
|
|
private function logStatusTransitionSideEffectFailure(string $context, Throwable $e): void
|
|
{
|
|
\Log::warning("{$context} for deployment {$this->deployment_uuid}: {$e->getMessage()}");
|
|
|
|
try {
|
|
$this->application_deployment_queue->addLogEntry("Warning: {$context}: {$e->getMessage()}", 'stderr');
|
|
} catch (Throwable) {
|
|
// The warning is already in the application log.
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Check if deployment is in a terminal state (FINISHED, FAILED or CANCELLED).
|
|
* Terminal states cannot be changed.
|
|
*/
|
|
private function isInTerminalState(): bool
|
|
{
|
|
$this->application_deployment_queue->refresh();
|
|
|
|
if ($this->application_deployment_queue->status === ApplicationDeploymentStatus::FINISHED->value) {
|
|
return true;
|
|
}
|
|
|
|
if ($this->application_deployment_queue->status === ApplicationDeploymentStatus::FAILED->value) {
|
|
return true;
|
|
}
|
|
|
|
if ($this->application_deployment_queue->status === ApplicationDeploymentStatus::CANCELLED_BY_USER->value) {
|
|
$this->application_deployment_queue->addLogEntry('Deployment cancelled by user, stopping execution.');
|
|
$this->handleCancelledDeployment();
|
|
throw new DeploymentException('Deployment cancelled by user', 69420);
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* A cancelled deployment on an additional server can be the last one of a multi-server
|
|
* deployment, so it must also trigger the combined notification.
|
|
*/
|
|
private function handleCancelledDeployment(): void
|
|
{
|
|
if (filled($this->application_deployment_queue->parent_deployment_uuid)) {
|
|
$this->sendMultiServerDeploymentNotification($this->application_deployment_queue->parent_deployment_uuid);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Update the deployment status in the database.
|
|
*/
|
|
private function updateDeploymentStatus(ApplicationDeploymentStatus $status): void
|
|
{
|
|
$this->application_deployment_queue->update([
|
|
'status' => $status->value,
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* Execute status-specific side effects (events, notifications, additional deployments).
|
|
*/
|
|
private function handleStatusTransition(ApplicationDeploymentStatus $status): void
|
|
{
|
|
match ($status) {
|
|
ApplicationDeploymentStatus::FINISHED => $this->handleSuccessfulDeployment(),
|
|
ApplicationDeploymentStatus::FAILED => $this->handleFailedDeployment(),
|
|
default => null,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Handle side effects when deployment succeeds.
|
|
*/
|
|
private function handleSuccessfulDeployment(): void
|
|
{
|
|
// Reset restart count after successful deployment
|
|
// This is done here (not in Livewire) to avoid race conditions
|
|
// with GetContainersStatus reading old container restart counts
|
|
$restartState = [
|
|
'restart_count' => 0,
|
|
'last_restart_at' => null,
|
|
'last_restart_type' => null,
|
|
];
|
|
|
|
if ($this->pull_request_id === 0) {
|
|
$restartState['restart_limit_reached'] = false;
|
|
}
|
|
|
|
if ($this->pull_request_id === 0) {
|
|
$this->application->update($restartState);
|
|
} else {
|
|
$this->preview?->resetRestartLimit();
|
|
}
|
|
|
|
try {
|
|
$this->application->markDeploymentConfigurationApplied($this->application_deployment_queue);
|
|
} catch (Exception $e) {
|
|
\Log::warning('Failed to mark configuration as applied for deployment '.$this->deployment_uuid.': '.$e->getMessage());
|
|
}
|
|
|
|
event(new ApplicationConfigurationChanged($this->application->team()->id));
|
|
|
|
if (filled($this->application_deployment_queue->parent_deployment_uuid)) {
|
|
$this->sendMultiServerDeploymentNotification($this->application_deployment_queue->parent_deployment_uuid);
|
|
|
|
return;
|
|
}
|
|
|
|
$queuedAdditionalDeployments = $this->only_this_server ? 0 : $this->deploy_to_additional_destinations();
|
|
if ($queuedAdditionalDeployments === 0) {
|
|
$this->sendDeploymentNotification(DeploymentSuccess::class);
|
|
|
|
return;
|
|
}
|
|
Cache::put("multi-server-deployment-queued:{$this->deployment_uuid}", true, now()->addDay());
|
|
$this->sendMultiServerDeploymentNotification($this->deployment_uuid);
|
|
}
|
|
|
|
/**
|
|
* Handle side effects when deployment fails.
|
|
*/
|
|
private function handleFailedDeployment(): void
|
|
{
|
|
if (filled($this->application_deployment_queue->parent_deployment_uuid)) {
|
|
$this->sendMultiServerDeploymentNotification($this->application_deployment_queue->parent_deployment_uuid);
|
|
|
|
return;
|
|
}
|
|
|
|
$this->sendDeploymentNotification(DeploymentFailed::class);
|
|
}
|
|
|
|
/**
|
|
* The main server queues one deployment for each additional server. When all are queued and the
|
|
* last one ends, Coolify sends one notification: success if all servers succeeded, else failed
|
|
* with a link to the first deployment that did not succeed.
|
|
*/
|
|
private function sendMultiServerDeploymentNotification(string $parentDeploymentUuid): void
|
|
{
|
|
if (! Cache::has("multi-server-deployment-queued:{$parentDeploymentUuid}")) {
|
|
return;
|
|
}
|
|
$deployments = ApplicationDeploymentQueue::query()
|
|
->where('parent_deployment_uuid', $parentDeploymentUuid)
|
|
->get(['deployment_uuid', 'status']);
|
|
|
|
$endStatuses = [
|
|
ApplicationDeploymentStatus::FINISHED->value,
|
|
ApplicationDeploymentStatus::FAILED->value,
|
|
ApplicationDeploymentStatus::CANCELLED_BY_USER->value,
|
|
];
|
|
if ($deployments->contains(fn (ApplicationDeploymentQueue $deployment) => ! in_array($deployment->status, $endStatuses, true))) {
|
|
return;
|
|
}
|
|
if (! Cache::add("multi-server-deployment-notified:{$parentDeploymentUuid}", true, now()->addDay())) {
|
|
return;
|
|
}
|
|
|
|
$unsuccessful = $deployments->first(fn (ApplicationDeploymentQueue $deployment) => $deployment->status !== ApplicationDeploymentStatus::FINISHED->value);
|
|
// Additional servers pull the image and do not clone, so only the main deployment has the commit message.
|
|
$parentDeployment = ApplicationDeploymentQueue::query()->where('deployment_uuid', $parentDeploymentUuid)->first();
|
|
$this->application->environment->project->team?->notify($unsuccessful
|
|
? new DeploymentFailed($this->application, $unsuccessful->deployment_uuid, $this->preview, $parentDeployment)
|
|
: new DeploymentSuccess($this->application, $parentDeploymentUuid, $this->preview, $parentDeployment));
|
|
}
|
|
|
|
/**
|
|
* Send deployment status notification to the team.
|
|
*/
|
|
private function sendDeploymentNotification(string $notificationClass): void
|
|
{
|
|
$this->application->environment->project->team?->notify(
|
|
new $notificationClass($this->application, $this->deployment_uuid, $this->preview, $this->application_deployment_queue)
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Complete deployment successfully.
|
|
* Sends success notification and triggers additional deployments if needed.
|
|
*/
|
|
private function completeDeployment(): void
|
|
{
|
|
$this->transitionToStatus(ApplicationDeploymentStatus::FINISHED);
|
|
}
|
|
|
|
/**
|
|
* Fail the deployment.
|
|
* Sends failure notification and queues next deployment.
|
|
*/
|
|
protected function failDeployment(): void
|
|
{
|
|
$this->transitionToStatus(ApplicationDeploymentStatus::FAILED);
|
|
}
|
|
|
|
public function failed(Throwable $exception): void
|
|
{
|
|
$this->failDeployment();
|
|
|
|
// failed() runs on a fresh job instance, so remote secrets fetched during the deployment are not loaded here.
|
|
// Every entry still passes through ApplicationDeploymentQueue::redactLogText(); raw stack traces are not written
|
|
// because their truncated arguments can hold partial secrets that redaction cannot match.
|
|
$errorMessage = $exception->getMessage() ?: 'Unknown error occurred';
|
|
$errorCode = $exception->getCode();
|
|
$errorClass = get_class($exception);
|
|
|
|
$this->application_deployment_queue->addLogEntry('========================================', 'stderr');
|
|
if ($exception instanceof TimeoutExceededException) {
|
|
$this->application_deployment_queue->addLogEntry("Deployment failed: Timeout of {$this->timeout} seconds exceeded. Increase the deployment timeout in the Server settings (Advanced > Builds).", 'stderr');
|
|
} elseif ($exception instanceof DeploymentException && $exception->isMessageAlreadyLogged()) {
|
|
$this->application_deployment_queue->addLogEntry('Deployment failed.', 'stderr');
|
|
} else {
|
|
$this->application_deployment_queue->addLogEntry("Deployment failed: {$errorMessage}", 'stderr');
|
|
}
|
|
$this->application_deployment_queue->addLogEntry("Error type: {$errorClass}", 'stderr', hidden: true);
|
|
$this->application_deployment_queue->addLogEntry("Error code: {$errorCode}", 'stderr', hidden: true);
|
|
|
|
// Log the exception file and line for debugging
|
|
$this->application_deployment_queue->addLogEntry("Location: {$exception->getFile()}:{$exception->getLine()}", 'stderr', hidden: true);
|
|
|
|
// Log previous exceptions if they exist (for chained exceptions)
|
|
$previous = $exception->getPrevious();
|
|
if ($previous) {
|
|
$this->application_deployment_queue->addLogEntry('Caused by:', 'stderr', hidden: true);
|
|
$previousMessage = $previous->getMessage() ?: 'No message';
|
|
$previousClass = get_class($previous);
|
|
$this->application_deployment_queue->addLogEntry(" {$previousClass}: {$previousMessage}", 'stderr', hidden: true);
|
|
$this->application_deployment_queue->addLogEntry(" at {$previous->getFile()}:{$previous->getLine()}", 'stderr', hidden: true);
|
|
}
|
|
$this->application_deployment_queue->addLogEntry('========================================', 'stderr');
|
|
|
|
if ($this->application->build_pack !== 'dockercompose') {
|
|
$code = $exception->getCode();
|
|
if ($code !== 69420) {
|
|
// 69420 means failed to push the image to the registry, so we don't need to remove the new version as it is the currently running one
|
|
if ($this->application->settings->is_consistent_container_name_enabled || $this->pull_request_id !== 0) {
|
|
// do not remove already running container for PR deployments
|
|
} elseif ($this->newVersionIsHealthy()) {
|
|
// The new container passed its health check, so the old container may already be removed.
|
|
// A later failure (additional destinations, queue advancement, ...) must not remove the only running version.
|
|
$this->application_deployment_queue->addLogEntry('Deployment failed after the new version became healthy. Keeping the new version of your application running.', 'stderr');
|
|
} else {
|
|
$this->application_deployment_queue->addLogEntry('Deployment failed. Removing the new version of your application.', 'stderr');
|
|
$this->removeContainerWithTimeout($this->container_name);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|