Merge branch 'next' into feat/improve-network-mode-check

This commit is contained in:
Andras Bacsai
2025-09-22 12:31:36 +02:00
committed by GitHub
791 changed files with 32444 additions and 8477 deletions

View File

@@ -176,4 +176,5 @@ function removeUnnecessaryFieldsFromRequest(Request $request)
$request->offsetUnset('private_key_uuid');
$request->offsetUnset('use_build_server');
$request->offsetUnset('is_static');
$request->offsetUnset('force_domain_override');
}

View File

@@ -1,12 +1,15 @@
<?php
use App\Actions\Application\StopApplication;
use App\Enums\ApplicationDeploymentStatus;
use App\Jobs\ApplicationDeploymentJob;
use App\Jobs\VolumeCloneJob;
use App\Models\Application;
use App\Models\ApplicationDeploymentQueue;
use App\Models\Server;
use App\Models\StandaloneDocker;
use Spatie\Url\Url;
use Visus\Cuid2\Cuid2;
function queue_application_deployment(Application $application, string $deployment_uuid, ?int $pull_request_id = 0, string $commit = 'HEAD', bool $force_rebuild = false, bool $is_webhook = false, bool $is_api = false, bool $restart_only = false, ?string $git_type = null, bool $no_questions_asked = false, ?Server $server = null, ?StandaloneDocker $destination = null, bool $only_this_server = false, bool $rollback = false)
{
@@ -68,7 +71,7 @@ function queue_application_deployment(Application $application, string $deployme
ApplicationDeploymentJob::dispatch(
application_deployment_queue_id: $deployment->id,
);
} elseif (next_queuable($server_id, $application_id, $commit)) {
} elseif (next_queuable($server_id, $application_id, $commit, $pull_request_id)) {
ApplicationDeploymentJob::dispatch(
application_deployment_queue_id: $deployment->id,
);
@@ -93,32 +96,31 @@ function force_start_deployment(ApplicationDeploymentQueue $deployment)
function queue_next_deployment(Application $application)
{
$server_id = $application->destination->server_id;
$next_found = ApplicationDeploymentQueue::where('server_id', $server_id)->where('status', ApplicationDeploymentStatus::QUEUED)->get()->sortBy('created_at')->first();
if ($next_found) {
$next_found->update([
'status' => ApplicationDeploymentStatus::IN_PROGRESS->value,
]);
$queued_deployments = ApplicationDeploymentQueue::where('server_id', $server_id)
->where('status', ApplicationDeploymentStatus::QUEUED)
->get()
->sortBy('created_at');
ApplicationDeploymentJob::dispatch(
application_deployment_queue_id: $next_found->id,
);
foreach ($queued_deployments as $next_deployment) {
// Check if this queued deployment can actually run
if (next_queuable($next_deployment->server_id, $next_deployment->application_id, $next_deployment->commit, $next_deployment->pull_request_id)) {
$next_deployment->update([
'status' => ApplicationDeploymentStatus::IN_PROGRESS->value,
]);
ApplicationDeploymentJob::dispatch(
application_deployment_queue_id: $next_deployment->id,
);
}
}
}
function next_queuable(string $server_id, string $application_id, string $commit = 'HEAD'): bool
function next_queuable(string $server_id, string $application_id, string $commit = 'HEAD', int $pull_request_id = 0): bool
{
// Check if there's already a deployment in progress for this application and commit
$existing_deployment = ApplicationDeploymentQueue::where('application_id', $application_id)
->where('commit', $commit)
->where('status', ApplicationDeploymentStatus::IN_PROGRESS->value)
->first();
if ($existing_deployment) {
return false;
}
// Check if there's any deployment in progress for this application
// Check if there's already a deployment in progress for this application with the same pull_request_id
// This allows normal deployments and PR deployments to run concurrently
$in_progress = ApplicationDeploymentQueue::where('application_id', $application_id)
->where('pull_request_id', $pull_request_id)
->where('status', ApplicationDeploymentStatus::IN_PROGRESS->value)
->exists();
@@ -142,13 +144,15 @@ function next_queuable(string $server_id, string $application_id, string $commit
function next_after_cancel(?Server $server = null)
{
if ($server) {
$next_found = ApplicationDeploymentQueue::where('server_id', data_get($server, 'id'))->where('status', ApplicationDeploymentStatus::QUEUED)->get()->sortBy('created_at');
$next_found = ApplicationDeploymentQueue::where('server_id', data_get($server, 'id'))
->where('status', ApplicationDeploymentStatus::QUEUED)
->get()
->sortBy('created_at');
if ($next_found->count() > 0) {
foreach ($next_found as $next) {
$server = Server::find($next->server_id);
$concurrent_builds = $server->settings->concurrent_builds;
$inprogress_deployments = ApplicationDeploymentQueue::where('server_id', $next->server_id)->whereIn('status', [ApplicationDeploymentStatus::QUEUED])->get()->sortByDesc('created_at');
if ($inprogress_deployments->count() < $concurrent_builds) {
// Use next_queuable to properly check if this deployment can run
if (next_queuable($next->server_id, $next->application_id, $next->commit, $next->pull_request_id)) {
$next->update([
'status' => ApplicationDeploymentStatus::IN_PROGRESS->value,
]);
@@ -157,8 +161,195 @@ function next_after_cancel(?Server $server = null)
application_deployment_queue_id: $next->id,
);
}
break;
}
}
}
}
function clone_application(Application $source, $destination, array $overrides = [], bool $cloneVolumeData = false): Application
{
$uuid = $overrides['uuid'] ?? (string) new Cuid2;
$server = $destination->server;
// Prepare name and URL
$name = $overrides['name'] ?? 'clone-of-'.str($source->name)->limit(20).'-'.$uuid;
$applicationSettings = $source->settings;
$url = $overrides['fqdn'] ?? $source->fqdn;
if ($server->proxyType() !== 'NONE' && $applicationSettings->is_container_label_readonly_enabled === true) {
$url = generateUrl(server: $server, random: $uuid);
}
// Clone the application
$newApplication = $source->replicate([
'id',
'created_at',
'updated_at',
'additional_servers_count',
'additional_networks_count',
])->fill(array_merge([
'uuid' => $uuid,
'name' => $name,
'fqdn' => $url,
'status' => 'exited',
'destination_id' => $destination->id,
], $overrides));
$newApplication->save();
// Update custom labels if needed
if ($newApplication->destination->server->proxyType() !== 'NONE' && $applicationSettings->is_container_label_readonly_enabled === true) {
$customLabels = str(implode('|coolify|', generateLabelsApplication($newApplication)))->replace('|coolify|', "\n");
$newApplication->custom_labels = base64_encode($customLabels);
$newApplication->save();
}
// Clone settings
$newApplication->settings()->delete();
if ($applicationSettings) {
$newApplicationSettings = $applicationSettings->replicate([
'id',
'created_at',
'updated_at',
])->fill([
'application_id' => $newApplication->id,
]);
$newApplicationSettings->save();
}
// Clone tags
$tags = $source->tags;
foreach ($tags as $tag) {
$newApplication->tags()->attach($tag->id);
}
// Clone scheduled tasks
$scheduledTasks = $source->scheduled_tasks()->get();
foreach ($scheduledTasks as $task) {
$newTask = $task->replicate([
'id',
'created_at',
'updated_at',
])->fill([
'uuid' => (string) new Cuid2,
'application_id' => $newApplication->id,
'team_id' => currentTeam()->id,
]);
$newTask->save();
}
// Clone previews with FQDN regeneration
$applicationPreviews = $source->previews()->get();
foreach ($applicationPreviews as $preview) {
$newPreview = $preview->replicate([
'id',
'created_at',
'updated_at',
])->fill([
'uuid' => (string) new Cuid2,
'application_id' => $newApplication->id,
'status' => 'exited',
'fqdn' => null,
'docker_compose_domains' => null,
]);
$newPreview->save();
// Regenerate FQDN for the cloned preview
if ($newApplication->build_pack === 'dockercompose') {
$newPreview->generate_preview_fqdn_compose();
} else {
$newPreview->generate_preview_fqdn();
}
}
// Clone persistent volumes
$persistentVolumes = $source->persistentStorages()->get();
foreach ($persistentVolumes as $volume) {
$newName = '';
if (str_starts_with($volume->name, $source->uuid)) {
$newName = str($volume->name)->replace($source->uuid, $newApplication->uuid);
} else {
$newName = $newApplication->uuid.'-'.str($volume->name)->afterLast('-');
}
$newPersistentVolume = $volume->replicate([
'id',
'created_at',
'updated_at',
])->fill([
'name' => $newName,
'resource_id' => $newApplication->id,
]);
$newPersistentVolume->save();
if ($cloneVolumeData) {
try {
StopApplication::dispatch($source, false, false);
$sourceVolume = $volume->name;
$targetVolume = $newPersistentVolume->name;
$sourceServer = $source->destination->server;
$targetServer = $newApplication->destination->server;
VolumeCloneJob::dispatch($sourceVolume, $targetVolume, $sourceServer, $targetServer, $newPersistentVolume);
queue_application_deployment(
deployment_uuid: (string) new Cuid2,
application: $source,
server: $sourceServer,
destination: $source->destination,
no_questions_asked: true
);
} catch (\Exception $e) {
\Log::error('Failed to copy volume data for '.$volume->name.': '.$e->getMessage());
}
}
}
// Clone file storages
$fileStorages = $source->fileStorages()->get();
foreach ($fileStorages as $storage) {
$newStorage = $storage->replicate([
'id',
'created_at',
'updated_at',
])->fill([
'resource_id' => $newApplication->id,
]);
$newStorage->save();
}
// Clone production environment variables without triggering the created hook
$environmentVariables = $source->environment_variables()->get();
foreach ($environmentVariables as $environmentVariable) {
\App\Models\EnvironmentVariable::withoutEvents(function () use ($environmentVariable, $newApplication) {
$newEnvironmentVariable = $environmentVariable->replicate([
'id',
'created_at',
'updated_at',
])->fill([
'resourceable_id' => $newApplication->id,
'resourceable_type' => $newApplication->getMorphClass(),
'is_preview' => false,
]);
$newEnvironmentVariable->save();
});
}
// Clone preview environment variables
$previewEnvironmentVariables = $source->environment_variables_preview()->get();
foreach ($previewEnvironmentVariables as $previewEnvironmentVariable) {
\App\Models\EnvironmentVariable::withoutEvents(function () use ($previewEnvironmentVariable, $newApplication) {
$newPreviewEnvironmentVariable = $previewEnvironmentVariable->replicate([
'id',
'created_at',
'updated_at',
])->fill([
'resourceable_id' => $newApplication->id,
'resourceable_type' => $newApplication->getMorphClass(),
'is_preview' => true,
]);
$newPreviewEnvironmentVariable->save();
});
}
return $newApplication;
}

View File

@@ -237,11 +237,18 @@ function removeOldBackups($backup): void
{
try {
if ($backup->executions) {
$localBackupsToDelete = deleteOldBackupsLocally($backup);
if ($localBackupsToDelete->isNotEmpty()) {
// If local backup is disabled, mark all executions as having local storage deleted
if ($backup->disable_local_backup && $backup->save_s3) {
$backup->executions()
->whereIn('id', $localBackupsToDelete->pluck('id'))
->where('local_storage_deleted', false)
->update(['local_storage_deleted' => true]);
} else {
$localBackupsToDelete = deleteOldBackupsLocally($backup);
if ($localBackupsToDelete->isNotEmpty()) {
$backup->executions()
->whereIn('id', $localBackupsToDelete->pluck('id'))
->update(['local_storage_deleted' => true]);
}
}
}
@@ -254,10 +261,18 @@ function removeOldBackups($backup): void
}
}
$backup->executions()
->where('local_storage_deleted', true)
->where('s3_storage_deleted', true)
->delete();
// Delete executions where both local and S3 storage are marked as deleted
// or where only S3 is enabled and S3 storage is deleted
if ($backup->disable_local_backup && $backup->save_s3) {
$backup->executions()
->where('s3_storage_deleted', true)
->delete();
} else {
$backup->executions()
->where('local_storage_deleted', true)
->where('s3_storage_deleted', true)
->delete();
}
} catch (\Exception $e) {
throw $e;

View File

@@ -256,12 +256,12 @@ function generateServiceSpecificFqdns(ServiceApplication|Application $resource)
if (str($MINIO_BROWSER_REDIRECT_URL->value ?? '')->isEmpty()) {
$MINIO_BROWSER_REDIRECT_URL->update([
'value' => generateFqdn($server, 'console-'.$uuid, true),
'value' => generateUrl(server: $server, random: 'console-'.$uuid, forceHttps: true),
]);
}
if (str($MINIO_SERVER_URL->value ?? '')->isEmpty()) {
$MINIO_SERVER_URL->update([
'value' => generateFqdn($server, 'minio-'.$uuid, true),
'value' => generateUrl(server: $server, random: 'minio-'.$uuid, forceHttps: true),
]);
}
$payload = collect([
@@ -279,12 +279,12 @@ function generateServiceSpecificFqdns(ServiceApplication|Application $resource)
if (str($LOGTO_ENDPOINT->value ?? '')->isEmpty()) {
$LOGTO_ENDPOINT->update([
'value' => generateFqdn($server, 'logto-'.$uuid),
'value' => generateUrl(server: $server, random: 'logto-'.$uuid),
]);
}
if (str($LOGTO_ADMIN_ENDPOINT->value ?? '')->isEmpty()) {
$LOGTO_ADMIN_ENDPOINT->update([
'value' => generateFqdn($server, 'logto-admin-'.$uuid),
'value' => generateUrl(server: $server, random: 'logto-admin-'.$uuid),
]);
}
$payload = collect([
@@ -1093,19 +1093,18 @@ function getContainerLogs(Server $server, string $container_id, int $lines = 100
{
if ($server->isSwarm()) {
$output = instant_remote_process([
"docker service logs -n {$lines} {$container_id}",
"docker service logs -n {$lines} {$container_id} 2>&1",
], $server);
} else {
$output = instant_remote_process([
"docker logs -n {$lines} {$container_id}",
"docker logs -n {$lines} {$container_id} 2>&1",
], $server);
}
$output .= removeAnsiColors($output);
$output = removeAnsiColors($output);
return $output;
}
function escapeEnvVariables($value)
{
$search = ['\\', "\r", "\t", "\x0", '"', "'"];

View File

@@ -0,0 +1,237 @@
<?php
use App\Models\Application;
use App\Models\ServiceApplication;
use Illuminate\Support\Collection;
function checkDomainUsage(ServiceApplication|Application|null $resource = null, ?string $domain = null)
{
$conflicts = [];
// Get the current team for filtering
$currentTeam = null;
if ($resource) {
$currentTeam = $resource->team();
}
if ($resource) {
if ($resource->getMorphClass() === Application::class && $resource->build_pack === 'dockercompose') {
$domains = data_get(json_decode($resource->docker_compose_domains, true), '*.domain');
$domains = collect($domains);
} else {
$domains = collect($resource->fqdns);
}
} elseif ($domain) {
$domains = collect([$domain]);
} else {
return ['conflicts' => [], 'hasConflicts' => false];
}
$domains = $domains->map(function ($domain) {
if (str($domain)->endsWith('/')) {
$domain = str($domain)->beforeLast('/');
}
return str($domain);
});
// Filter applications by team if we have a current team
$appsQuery = Application::query();
if ($currentTeam) {
$appsQuery = $appsQuery->whereHas('environment.project', function ($query) use ($currentTeam) {
$query->where('team_id', $currentTeam->id);
});
}
$apps = $appsQuery->get();
foreach ($apps as $app) {
$list_of_domains = collect(explode(',', $app->fqdn))->filter(fn ($fqdn) => $fqdn !== '');
foreach ($list_of_domains as $domain) {
if (str($domain)->endsWith('/')) {
$domain = str($domain)->beforeLast('/');
}
$naked_domain = str($domain)->value();
if ($domains->contains($naked_domain)) {
if (data_get($resource, 'uuid')) {
if ($resource->uuid !== $app->uuid) {
$conflicts[] = [
'domain' => $naked_domain,
'resource_name' => $app->name,
'resource_link' => $app->link(),
'resource_type' => 'application',
'message' => "Domain $naked_domain is already in use by application '{$app->name}'",
];
}
} elseif ($domain) {
$conflicts[] = [
'domain' => $naked_domain,
'resource_name' => $app->name,
'resource_link' => $app->link(),
'resource_type' => 'application',
'message' => "Domain $naked_domain is already in use by application '{$app->name}'",
];
}
}
}
}
// Filter service applications by team if we have a current team
$serviceAppsQuery = ServiceApplication::query();
if ($currentTeam) {
$serviceAppsQuery = $serviceAppsQuery->whereHas('service.environment.project', function ($query) use ($currentTeam) {
$query->where('team_id', $currentTeam->id);
});
}
$apps = $serviceAppsQuery->get();
foreach ($apps as $app) {
$list_of_domains = collect(explode(',', $app->fqdn))->filter(fn ($fqdn) => $fqdn !== '');
foreach ($list_of_domains as $domain) {
if (str($domain)->endsWith('/')) {
$domain = str($domain)->beforeLast('/');
}
$naked_domain = str($domain)->value();
if ($domains->contains($naked_domain)) {
if (data_get($resource, 'uuid')) {
if ($resource->uuid !== $app->uuid) {
$conflicts[] = [
'domain' => $naked_domain,
'resource_name' => $app->service->name,
'resource_link' => $app->service->link(),
'resource_type' => 'service',
'message' => "Domain $naked_domain is already in use by service '{$app->service->name}'",
];
}
} elseif ($domain) {
$conflicts[] = [
'domain' => $naked_domain,
'resource_name' => $app->service->name,
'resource_link' => $app->service->link(),
'resource_type' => 'service',
'message' => "Domain $naked_domain is already in use by service '{$app->service->name}'",
];
}
}
}
}
if ($resource) {
$settings = instanceSettings();
if (data_get($settings, 'fqdn')) {
$domain = data_get($settings, 'fqdn');
if (str($domain)->endsWith('/')) {
$domain = str($domain)->beforeLast('/');
}
$naked_domain = str($domain)->value();
if ($domains->contains($naked_domain)) {
$conflicts[] = [
'domain' => $naked_domain,
'resource_name' => 'Coolify Instance',
'resource_link' => '#',
'resource_type' => 'instance',
'message' => "Domain $naked_domain is already in use by this Coolify instance",
];
}
}
}
return [
'conflicts' => $conflicts,
'hasConflicts' => count($conflicts) > 0,
];
}
function checkIfDomainIsAlreadyUsedViaAPI(Collection|array $domains, ?string $teamId = null, ?string $uuid = null)
{
$conflicts = [];
if (is_null($teamId)) {
return ['error' => 'Team ID is required.'];
}
if (is_array($domains)) {
$domains = collect($domains);
}
$domains = $domains->map(function ($domain) {
if (str($domain)->endsWith('/')) {
$domain = str($domain)->beforeLast('/');
}
return str($domain);
});
// Check applications within the same team
$applications = Application::ownedByCurrentTeamAPI($teamId)->get(['fqdn', 'uuid', 'name', 'id']);
$serviceApplications = ServiceApplication::ownedByCurrentTeamAPI($teamId)->with('service:id,name')->get(['fqdn', 'uuid', 'id', 'service_id']);
if ($uuid) {
$applications = $applications->filter(fn ($app) => $app->uuid !== $uuid);
$serviceApplications = $serviceApplications->filter(fn ($app) => $app->uuid !== $uuid);
}
foreach ($applications as $app) {
if (is_null($app->fqdn)) {
continue;
}
$list_of_domains = collect(explode(',', $app->fqdn))->filter(fn ($fqdn) => $fqdn !== '');
foreach ($list_of_domains as $domain) {
if (str($domain)->endsWith('/')) {
$domain = str($domain)->beforeLast('/');
}
$naked_domain = str($domain)->value();
if ($domains->contains($naked_domain)) {
$conflicts[] = [
'domain' => $naked_domain,
'resource_name' => $app->name,
'resource_uuid' => $app->uuid,
'resource_type' => 'application',
'message' => "Domain $naked_domain is already in use by application '{$app->name}'",
];
}
}
}
foreach ($serviceApplications as $app) {
if (str($app->fqdn)->isEmpty()) {
continue;
}
$list_of_domains = collect(explode(',', $app->fqdn))->filter(fn ($fqdn) => $fqdn !== '');
foreach ($list_of_domains as $domain) {
if (str($domain)->endsWith('/')) {
$domain = str($domain)->beforeLast('/');
}
$naked_domain = str($domain)->value();
if ($domains->contains($naked_domain)) {
$conflicts[] = [
'domain' => $naked_domain,
'resource_name' => $app->service->name ?? 'Unknown Service',
'resource_uuid' => $app->uuid,
'resource_type' => 'service',
'message' => "Domain $naked_domain is already in use by service '{$app->service->name}'",
];
}
}
}
// Check instance-level domain
$settings = instanceSettings();
if (data_get($settings, 'fqdn')) {
$domain = data_get($settings, 'fqdn');
if (str($domain)->endsWith('/')) {
$domain = str($domain)->beforeLast('/');
}
$naked_domain = str($domain)->value();
if ($domains->contains($naked_domain)) {
$conflicts[] = [
'domain' => $naked_domain,
'resource_name' => 'Coolify Instance',
'resource_uuid' => null,
'resource_type' => 'instance',
'message' => "Domain $naked_domain is already in use by this Coolify instance",
];
}
}
return [
'conflicts' => $conflicts,
'hasConflicts' => count($conflicts) > 0,
];
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
<?php
use App\Actions\Proxy\SaveConfiguration;
use App\Actions\Proxy\SaveProxyConfiguration;
use App\Enums\ProxyTypes;
use App\Models\Application;
use App\Models\Server;
@@ -130,10 +130,16 @@ function generate_default_proxy_configuration(Server $server)
}
$array_of_networks = collect([]);
$networks->map(function ($network) use ($array_of_networks) {
$filtered_networks = collect([]);
$networks->map(function ($network) use ($array_of_networks, $filtered_networks) {
if ($network === 'host') {
return; // network-scoped alias is supported only for containers in user defined networks
}
$array_of_networks[$network] = [
'external' => true,
];
$filtered_networks->push($network);
});
if ($proxy_type === ProxyTypes::TRAEFIK->value) {
$labels = [
@@ -155,7 +161,7 @@ function generate_default_proxy_configuration(Server $server)
'extra_hosts' => [
'host.docker.internal:host-gateway',
],
'networks' => $networks->toArray(),
'networks' => $filtered_networks->toArray(),
'ports' => [
'80:80',
'443:443',
@@ -237,7 +243,7 @@ function generate_default_proxy_configuration(Server $server)
'CADDY_DOCKER_POLLING_INTERVAL=5s',
'CADDY_DOCKER_CADDYFILE_PATH=/dynamic/Caddyfile',
],
'networks' => $networks->toArray(),
'networks' => $filtered_networks->toArray(),
'ports' => [
'80:80',
'443:443',
@@ -261,7 +267,7 @@ function generate_default_proxy_configuration(Server $server)
}
$config = Yaml::dump($config, 12, 2);
SaveConfiguration::run($server, $config);
SaveProxyConfiguration::run($server, $config);
return $config;
}

View File

@@ -60,15 +60,86 @@ function remote_process(
function instant_scp(string $source, string $dest, Server $server, $throwError = true)
{
$scp_command = SshMultiplexingHelper::generateScpCommand($server, $source, $dest);
$process = Process::timeout(config('constants.ssh.command_timeout'))->run($scp_command);
$output = trim($process->output());
$exitCode = $process->exitCode();
if ($exitCode !== 0) {
return $throwError ? excludeCertainErrors($process->errorOutput(), $exitCode) : null;
}
return \App\Helpers\SshRetryHandler::retry(
function () use ($source, $dest, $server) {
$scp_command = SshMultiplexingHelper::generateScpCommand($server, $source, $dest);
$process = Process::timeout(config('constants.ssh.command_timeout'))->run($scp_command);
return $output === 'null' ? null : $output;
$output = trim($process->output());
$exitCode = $process->exitCode();
if ($exitCode !== 0) {
excludeCertainErrors($process->errorOutput(), $exitCode);
}
return $output === 'null' ? null : $output;
},
[
'server' => $server->ip,
'source' => $source,
'dest' => $dest,
'function' => 'instant_scp',
],
$throwError
);
}
function transfer_file_to_container(string $content, string $container_path, string $deployment_uuid, Server $server, bool $throwError = true): ?string
{
$temp_file = tempnam(sys_get_temp_dir(), 'coolify_env_');
try {
// Write content to temporary file
file_put_contents($temp_file, $content);
// Generate unique filename for server transfer
$server_temp_file = '/tmp/coolify_env_'.uniqid().'_'.$deployment_uuid;
// Transfer file to server
instant_scp($temp_file, $server_temp_file, $server, $throwError);
// Ensure parent directory exists in container, then copy file
$parent_dir = dirname($container_path);
$commands = [];
if ($parent_dir !== '.' && $parent_dir !== '/') {
$commands[] = executeInDocker($deployment_uuid, "mkdir -p \"$parent_dir\"");
}
$commands[] = "docker cp $server_temp_file $deployment_uuid:$container_path";
$commands[] = "rm -f $server_temp_file"; // Cleanup server temp file
return instant_remote_process_with_timeout($commands, $server, $throwError);
} finally {
// Always cleanup local temp file
if (file_exists($temp_file)) {
unlink($temp_file);
}
}
}
function transfer_file_to_server(string $content, string $server_path, Server $server, bool $throwError = true): ?string
{
$temp_file = tempnam(sys_get_temp_dir(), 'coolify_env_');
try {
// Write content to temporary file
file_put_contents($temp_file, $content);
// Ensure parent directory exists on server
$parent_dir = dirname($server_path);
if ($parent_dir !== '.' && $parent_dir !== '/') {
instant_remote_process_with_timeout(["mkdir -p \"$parent_dir\""], $server, $throwError);
}
// Transfer file directly to server destination
return instant_scp($temp_file, $server_path, $server, $throwError);
} finally {
// Always cleanup local temp file
if (file_exists($temp_file)) {
unlink($temp_file);
}
}
}
function instant_remote_process_with_timeout(Collection|array $command, Server $server, bool $throwError = true, bool $no_sudo = false): ?string
@@ -79,54 +150,65 @@ function instant_remote_process_with_timeout(Collection|array $command, Server $
}
$command_string = implode("\n", $command);
// $start_time = microtime(true);
$sshCommand = SshMultiplexingHelper::generateSshCommand($server, $command_string);
$process = Process::timeout(30)->run($sshCommand);
// $end_time = microtime(true);
return \App\Helpers\SshRetryHandler::retry(
function () use ($server, $command_string) {
$sshCommand = SshMultiplexingHelper::generateSshCommand($server, $command_string);
$process = Process::timeout(30)->run($sshCommand);
// $execution_time = ($end_time - $start_time) * 1000; // Convert to milliseconds
// ray('SSH command execution time:', $execution_time.' ms')->orange();
$output = trim($process->output());
$exitCode = $process->exitCode();
$output = trim($process->output());
$exitCode = $process->exitCode();
if ($exitCode !== 0) {
excludeCertainErrors($process->errorOutput(), $exitCode);
}
if ($exitCode !== 0) {
return $throwError ? excludeCertainErrors($process->errorOutput(), $exitCode) : null;
}
// Sanitize output to ensure valid UTF-8 encoding
$output = $output === 'null' ? null : sanitize_utf8_text($output);
// Sanitize output to ensure valid UTF-8 encoding
$output = $output === 'null' ? null : sanitize_utf8_text($output);
return $output;
return $output;
},
[
'server' => $server->ip,
'command_preview' => substr($command_string, 0, 100),
'function' => 'instant_remote_process_with_timeout',
],
$throwError
);
}
function instant_remote_process(Collection|array $command, Server $server, bool $throwError = true, bool $no_sudo = false): ?string
{
$command = $command instanceof Collection ? $command->toArray() : $command;
if ($server->isNonRoot() && ! $no_sudo) {
$command = parseCommandsByLineForSudo(collect($command), $server);
}
$command_string = implode("\n", $command);
// $start_time = microtime(true);
$sshCommand = SshMultiplexingHelper::generateSshCommand($server, $command_string);
$process = Process::timeout(config('constants.ssh.command_timeout'))->run($sshCommand);
// $end_time = microtime(true);
return \App\Helpers\SshRetryHandler::retry(
function () use ($server, $command_string) {
$sshCommand = SshMultiplexingHelper::generateSshCommand($server, $command_string);
$process = Process::timeout(config('constants.ssh.command_timeout'))->run($sshCommand);
// $execution_time = ($end_time - $start_time) * 1000; // Convert to milliseconds
// ray('SSH command execution time:', $execution_time.' ms')->orange();
$output = trim($process->output());
$exitCode = $process->exitCode();
$output = trim($process->output());
$exitCode = $process->exitCode();
if ($exitCode !== 0) {
excludeCertainErrors($process->errorOutput(), $exitCode);
}
if ($exitCode !== 0) {
return $throwError ? excludeCertainErrors($process->errorOutput(), $exitCode) : null;
}
// Sanitize output to ensure valid UTF-8 encoding
$output = $output === 'null' ? null : sanitize_utf8_text($output);
// Sanitize output to ensure valid UTF-8 encoding
$output = $output === 'null' ? null : sanitize_utf8_text($output);
return $output;
return $output;
},
[
'server' => $server->ip,
'command_preview' => substr($command_string, 0, 100),
'function' => 'instant_remote_process',
],
$throwError
);
}
function excludeCertainErrors(string $errorOutput, ?int $exitCode = null)
@@ -136,11 +218,18 @@ function excludeCertainErrors(string $errorOutput, ?int $exitCode = null)
'Could not resolve hostname',
]);
$ignored = $ignoredErrors->contains(fn ($error) => Str::contains($errorOutput, $error));
// Ensure we always have a meaningful error message
$errorMessage = trim($errorOutput);
if (empty($errorMessage)) {
$errorMessage = "SSH command failed with exit code: $exitCode";
}
if ($ignored) {
// TODO: Create new exception and disable in sentry
throw new \RuntimeException($errorOutput, $exitCode);
throw new \RuntimeException($errorMessage, $exitCode);
}
throw new \RuntimeException($errorOutput, $exitCode);
throw new \RuntimeException($errorMessage, $exitCode);
}
function decode_remote_command_output(?ApplicationDeploymentQueue $application_deployment_queue = null): Collection

View File

@@ -1,7 +1,6 @@
<?php
use App\Models\Application;
use App\Models\EnvironmentVariable;
use App\Models\Service;
use App\Models\ServiceApplication;
use App\Models\ServiceDatabase;
@@ -115,163 +114,67 @@ function updateCompose(ServiceApplication|ServiceDatabase $resource)
$resource->image = $updatedImage;
$resource->save();
}
$serviceName = str($resource->name)->upper()->replace('-', '_')->replace('.', '_');
$resource->service->environment_variables()->where('key', 'LIKE', "SERVICE_FQDN_{$serviceName}%")->delete();
$resource->service->environment_variables()->where('key', 'LIKE', "SERVICE_URL_{$serviceName}%")->delete();
if ($resource->fqdn) {
$resourceFqdns = str($resource->fqdn)->explode(',');
if ($resourceFqdns->count() === 1) {
$resourceFqdns = $resourceFqdns->first();
$variableName = 'SERVICE_FQDN_'.str($resource->name)->upper()->replace('-', '_');
$fqdn = Url::fromString($resourceFqdns);
$port = $fqdn->getPort();
$path = $fqdn->getPath();
$fqdn = $fqdn->getScheme().'://'.$fqdn->getHost();
$fqdnValue = ($path === '/') ? $fqdn : $fqdn.$path;
EnvironmentVariable::updateOrCreate([
'resourceable_type' => Service::class,
'resourceable_id' => $resource->service_id,
'key' => $variableName,
], [
'value' => $fqdnValue,
'is_build_time' => false,
'is_preview' => false,
]);
if ($port) {
$variableName = $variableName."_$port";
EnvironmentVariable::updateOrCreate([
'resourceable_type' => Service::class,
'resourceable_id' => $resource->service_id,
'key' => $variableName,
], [
'value' => $fqdnValue,
'is_build_time' => false,
'is_preview' => false,
]);
}
$variableName = 'SERVICE_URL_'.str($resource->name)->upper()->replace('-', '_');
$url = Url::fromString($fqdn);
$port = $url->getPort();
$path = $url->getPath();
$url = $url->getHost();
$urlValue = str($fqdn)->after('://');
if ($path !== '/') {
$urlValue = $urlValue.$path;
}
EnvironmentVariable::updateOrCreate([
$resourceFqdns = $resourceFqdns->first();
$variableName = 'SERVICE_URL_'.str($resource->name)->upper()->replace('-', '_')->replace('.', '_');
$url = Url::fromString($resourceFqdns);
$port = $url->getPort();
$path = $url->getPath();
$urlValue = $url->getScheme().'://'.$url->getHost();
$urlValue = ($path === '/') ? $urlValue : $urlValue.$path;
$resource->service->environment_variables()->updateOrCreate([
'resourceable_type' => Service::class,
'resourceable_id' => $resource->service_id,
'key' => $variableName,
], [
'value' => $urlValue,
'is_preview' => false,
]);
if ($port) {
$variableName = $variableName."_$port";
$resource->service->environment_variables()->updateOrCreate([
'resourceable_type' => Service::class,
'resourceable_id' => $resource->service_id,
'key' => $variableName,
], [
'value' => $urlValue,
'is_build_time' => false,
'is_preview' => false,
]);
if ($port) {
$variableName = $variableName."_$port";
EnvironmentVariable::updateOrCreate([
'resourceable_type' => Service::class,
'resourceable_id' => $resource->service_id,
'key' => $variableName,
], [
'value' => $urlValue,
'is_build_time' => false,
'is_preview' => false,
]);
}
} elseif ($resourceFqdns->count() > 1) {
foreach ($resourceFqdns as $fqdn) {
$host = Url::fromString($fqdn);
$port = $host->getPort();
$url = $host->getHost();
$path = $host->getPath();
$host = $host->getScheme().'://'.$host->getHost();
if ($port) {
$port_envs = EnvironmentVariable::where('resourceable_type', Service::class)
->where('resourceable_id', $resource->service_id)
->where('key', 'like', "SERVICE_FQDN_%_$port")
->get();
foreach ($port_envs as $port_env) {
$service_fqdn = str($port_env->key)->beforeLast('_')->after('SERVICE_FQDN_');
$env = EnvironmentVariable::where('resourceable_type', Service::class)
->where('resourceable_id', $resource->service_id)
->where('key', 'SERVICE_FQDN_'.$service_fqdn)
->first();
if ($env) {
if ($path === '/') {
$env->value = $host;
} else {
$env->value = $host.$path;
}
$env->save();
}
if ($path === '/') {
$port_env->value = $host;
} else {
$port_env->value = $host.$path;
}
$port_env->save();
}
$port_envs_url = EnvironmentVariable::where('resourceable_type', Service::class)
->where('resourceable_id', $resource->service_id)
->where('key', 'like', "SERVICE_URL_%_$port")
->get();
foreach ($port_envs_url as $port_env_url) {
$service_url = str($port_env_url->key)->beforeLast('_')->after('SERVICE_URL_');
$env = EnvironmentVariable::where('resourceable_type', Service::class)
->where('resourceable_id', $resource->service_id)
->where('key', 'SERVICE_URL_'.$service_url)
->first();
if ($env) {
if ($path === '/') {
$env->value = $url;
} else {
$env->value = $url.$path;
}
$env->save();
}
if ($path === '/') {
$port_env_url->value = $url;
} else {
$port_env_url->value = $url.$path;
}
$port_env_url->save();
}
} else {
$variableName = 'SERVICE_FQDN_'.str($resource->name)->upper()->replace('-', '_');
$generatedEnv = EnvironmentVariable::where('resourceable_type', Service::class)
->where('resourceable_id', $resource->service_id)
->where('key', $variableName)
->first();
$fqdn = Url::fromString($fqdn);
$fqdn = $fqdn->getScheme().'://'.$fqdn->getHost().$fqdn->getPath();
if ($generatedEnv) {
$generatedEnv->value = $fqdn;
$generatedEnv->save();
}
$variableName = 'SERVICE_URL_'.str($resource->name)->upper()->replace('-', '_');
$generatedEnv = EnvironmentVariable::where('resourceable_type', Service::class)
->where('resourceable_id', $resource->service_id)
->where('key', $variableName)
->first();
$url = Url::fromString($fqdn);
$url = $url->getHost().$url->getPath();
if ($generatedEnv) {
$url = str($fqdn)->after('://');
$generatedEnv->value = $url;
$generatedEnv->save();
}
}
}
}
} else {
// If FQDN is removed, delete the corresponding environment variables
$serviceName = str($resource->name)->upper()->replace('-', '_');
EnvironmentVariable::where('resourceable_type', Service::class)
->where('resourceable_id', $resource->service_id)
->where('key', 'LIKE', "SERVICE_FQDN_{$serviceName}%")
->delete();
EnvironmentVariable::where('resourceable_type', Service::class)
->where('resourceable_id', $resource->service_id)
->where('key', 'LIKE', "SERVICE_URL_{$serviceName}%")
->delete();
$variableName = 'SERVICE_FQDN_'.str($resource->name)->upper()->replace('-', '_')->replace('.', '_');
$fqdn = Url::fromString($resourceFqdns);
$port = $fqdn->getPort();
$path = $fqdn->getPath();
$fqdn = $fqdn->getHost();
$fqdnValue = str($fqdn)->after('://');
if ($path !== '/') {
$fqdnValue = $fqdnValue.$path;
}
$resource->service->environment_variables()->updateOrCreate([
'resourceable_type' => Service::class,
'resourceable_id' => $resource->service_id,
'key' => $variableName,
], [
'value' => $fqdnValue,
'is_preview' => false,
]);
if ($port) {
$variableName = $variableName."_$port";
$resource->service->environment_variables()->updateOrCreate([
'resourceable_type' => Service::class,
'resourceable_id' => $resource->service_id,
'key' => $variableName,
], [
'value' => $fqdnValue,
'is_preview' => false,
]);
}
}
} catch (\Throwable $e) {
return handleError($e);

File diff suppressed because it is too large Load Diff

View File

@@ -89,3 +89,22 @@ function allowedPathsForInvalidAccounts()
'livewire/update',
];
}
function updateStripeCustomerEmail(Team $team, string $newEmail): void
{
if (! isStripe()) {
return;
}
$stripe_customer_id = data_get($team, 'subscription.stripe_customer_id');
if (! $stripe_customer_id) {
return;
}
Stripe::setApiKey(config('subscription.stripe_api_key'));
\Stripe\Customer::update(
$stripe_customer_id,
['email' => $newEmail]
);
}

101
bootstrap/helpers/sudo.php Normal file
View File

@@ -0,0 +1,101 @@
<?php
use App\Models\Server;
use Illuminate\Support\Collection;
use Illuminate\Support\Str;
function shouldChangeOwnership(string $path): bool
{
$path = trim($path);
$systemPaths = ['/var', '/etc', '/usr', '/opt', '/sys', '/proc', '/dev', '/bin', '/sbin', '/lib', '/lib64', '/boot', '/root', '/home', '/media', '/mnt', '/srv', '/run'];
foreach ($systemPaths as $systemPath) {
if ($path === $systemPath || Str::startsWith($path, $systemPath.'/')) {
return false;
}
}
$isCoolifyPath = Str::startsWith($path, '/data/coolify') || Str::startsWith($path, '/tmp/coolify');
return $isCoolifyPath;
}
function parseCommandsByLineForSudo(Collection $commands, Server $server): array
{
$commands = $commands->map(function ($line) {
if (
! str(trim($line))->startsWith([
'cd',
'command',
'echo',
'true',
'if',
'fi',
])
) {
return "sudo $line";
}
if (str(trim($line))->startsWith('if')) {
return str_replace('if', 'if sudo', $line);
}
return $line;
});
$commands = $commands->map(function ($line) use ($server) {
if (Str::startsWith($line, 'sudo mkdir -p')) {
$path = trim(Str::after($line, 'sudo mkdir -p'));
if (shouldChangeOwnership($path)) {
return "$line && sudo chown -R $server->user:$server->user $path && sudo chmod -R o-rwx $path";
}
return $line;
}
return $line;
});
$commands = $commands->map(function ($line) {
$line = str($line);
if (str($line)->contains('$(')) {
$line = $line->replace('$(', '$(sudo ');
}
if (str($line)->contains('||')) {
$line = $line->replace('||', '|| sudo');
}
if (str($line)->contains('&&')) {
$line = $line->replace('&&', '&& sudo');
}
if (str($line)->contains(' | ')) {
$line = $line->replace(' | ', ' | sudo ');
}
return $line->value();
});
return $commands->toArray();
}
function parseLineForSudo(string $command, Server $server): string
{
if (! str($command)->startSwith('cd') && ! str($command)->startSwith('command')) {
$command = "sudo $command";
}
if (Str::startsWith($command, 'sudo mkdir -p')) {
$path = trim(Str::after($command, 'sudo mkdir -p'));
if (shouldChangeOwnership($path)) {
$command = "$command && sudo chown -R $server->user:$server->user $path && sudo chmod -R o-rwx $path";
}
}
if (str($command)->contains('$(') || str($command)->contains('`')) {
$command = str($command)->replace('$(', '$(sudo ')->replace('`', '`sudo ')->value();
}
if (str($command)->contains('||')) {
$command = str($command)->replace('||', '|| sudo ')->value();
}
if (str($command)->contains('&&')) {
$command = str($command)->replace('&&', '&& sudo ')->value();
}
return $command;
}