diff --git a/app/Actions/Database/StartDatabaseProxy.php b/app/Actions/Database/StartDatabaseProxy.php index 21fd6eb97..5c2f4d7fd 100644 --- a/app/Actions/Database/StartDatabaseProxy.php +++ b/app/Actions/Database/StartDatabaseProxy.php @@ -47,7 +47,6 @@ class StartDatabaseProxy if ($isSSLEnabled) { $internalPort = match ($databaseType) { 'standalone-redis', 'standalone-keydb', 'standalone-dragonfly' => 6380, - default => throw new \Exception("Unsupported database type: $databaseType"), }; } diff --git a/app/Console/Commands/CleanupRedis.php b/app/Console/Commands/CleanupRedis.php index 315d1adc7..a13cda0b8 100644 --- a/app/Console/Commands/CleanupRedis.php +++ b/app/Console/Commands/CleanupRedis.php @@ -7,26 +7,270 @@ use Illuminate\Support\Facades\Redis; class CleanupRedis extends Command { - protected $signature = 'cleanup:redis'; + protected $signature = 'cleanup:redis {--dry-run : Show what would be deleted without actually deleting} {--skip-overlapping : Skip overlapping queue cleanup}'; - protected $description = 'Cleanup Redis'; + protected $description = 'Cleanup Redis (Horizon jobs, metrics, overlapping queues, and related data)'; public function handle() { $redis = Redis::connection('horizon'); - $keys = $redis->keys('*'); $prefix = config('horizon.prefix'); + $dryRun = $this->option('dry-run'); + $skipOverlapping = $this->option('skip-overlapping'); + + if ($dryRun) { + $this->info('DRY RUN MODE - No data will be deleted'); + } + + $deletedCount = 0; + $totalKeys = 0; + + // Get all keys with the horizon prefix + $keys = $redis->keys('*'); + $totalKeys = count($keys); + + $this->info("Scanning {$totalKeys} keys for cleanup..."); + foreach ($keys as $key) { $keyWithoutPrefix = str_replace($prefix, '', $key); $type = $redis->command('type', [$keyWithoutPrefix]); + // Handle hash-type keys (individual jobs) if ($type === 5) { - $data = $redis->command('hgetall', [$keyWithoutPrefix]); - $status = data_get($data, 'status'); - if ($status === 'completed') { - $redis->command('del', [$keyWithoutPrefix]); + if ($this->shouldDeleteHashKey($redis, $keyWithoutPrefix, $dryRun)) { + $deletedCount++; + } + } + // Handle other key types (metrics, lists, etc.) + else { + if ($this->shouldDeleteOtherKey($redis, $keyWithoutPrefix, $key, $dryRun)) { + $deletedCount++; } } } + + // Clean up overlapping queues if not skipped + if (! $skipOverlapping) { + $this->info('Cleaning up overlapping queues...'); + $overlappingCleaned = $this->cleanupOverlappingQueues($redis, $prefix, $dryRun); + $deletedCount += $overlappingCleaned; + } + + if ($dryRun) { + $this->info("DRY RUN: Would delete {$deletedCount} out of {$totalKeys} keys"); + } else { + $this->info("Deleted {$deletedCount} out of {$totalKeys} keys"); + } + } + + private function shouldDeleteHashKey($redis, $keyWithoutPrefix, $dryRun) + { + $data = $redis->command('hgetall', [$keyWithoutPrefix]); + $status = data_get($data, 'status'); + + // Delete completed and failed jobs + if (in_array($status, ['completed', 'failed'])) { + if ($dryRun) { + $this->line("Would delete job: {$keyWithoutPrefix} (status: {$status})"); + } else { + $redis->command('del', [$keyWithoutPrefix]); + $this->line("Deleted job: {$keyWithoutPrefix} (status: {$status})"); + } + + return true; + } + + return false; + } + + private function shouldDeleteOtherKey($redis, $keyWithoutPrefix, $fullKey, $dryRun) + { + // Clean up various Horizon data structures + $patterns = [ + 'recent_jobs' => 'Recent jobs list', + 'failed_jobs' => 'Failed jobs list', + 'completed_jobs' => 'Completed jobs list', + 'job_classes' => 'Job classes metrics', + 'queues' => 'Queue metrics', + 'processes' => 'Process metrics', + 'supervisors' => 'Supervisor data', + 'metrics' => 'General metrics', + 'workload' => 'Workload data', + ]; + + foreach ($patterns as $pattern => $description) { + if (str_contains($keyWithoutPrefix, $pattern)) { + if ($dryRun) { + $this->line("Would delete {$description}: {$keyWithoutPrefix}"); + } else { + $redis->command('del', [$keyWithoutPrefix]); + $this->line("Deleted {$description}: {$keyWithoutPrefix}"); + } + + return true; + } + } + + // Clean up old timestamped data (older than 7 days) + if (preg_match('/(\d{10})/', $keyWithoutPrefix, $matches)) { + $timestamp = (int) $matches[1]; + $weekAgo = now()->subDays(7)->timestamp; + + if ($timestamp < $weekAgo) { + if ($dryRun) { + $this->line("Would delete old timestamped data: {$keyWithoutPrefix}"); + } else { + $redis->command('del', [$keyWithoutPrefix]); + $this->line("Deleted old timestamped data: {$keyWithoutPrefix}"); + } + + return true; + } + } + + return false; + } + + private function cleanupOverlappingQueues($redis, $prefix, $dryRun) + { + $cleanedCount = 0; + $queueKeys = []; + + // Find all queue-related keys + $allKeys = $redis->keys('*'); + foreach ($allKeys as $key) { + $keyWithoutPrefix = str_replace($prefix, '', $key); + if (str_contains($keyWithoutPrefix, 'queue:') || preg_match('/queues?[:\-]/', $keyWithoutPrefix)) { + $queueKeys[] = $keyWithoutPrefix; + } + } + + $this->info('Found '.count($queueKeys).' queue-related keys'); + + // Group queues by name pattern to find duplicates + $queueGroups = []; + foreach ($queueKeys as $queueKey) { + // Extract queue name (remove timestamps, suffixes) + $baseName = preg_replace('/[:\-]\d+$/', '', $queueKey); + $baseName = preg_replace('/[:\-](pending|reserved|delayed|processing)$/', '', $baseName); + + if (! isset($queueGroups[$baseName])) { + $queueGroups[$baseName] = []; + } + $queueGroups[$baseName][] = $queueKey; + } + + // Process each group for overlaps + foreach ($queueGroups as $baseName => $keys) { + if (count($keys) > 1) { + $cleanedCount += $this->deduplicateQueueGroup($redis, $baseName, $keys, $dryRun); + } + + // Also check for duplicate jobs within individual queues + foreach ($keys as $queueKey) { + $cleanedCount += $this->deduplicateQueueContents($redis, $queueKey, $dryRun); + } + } + + return $cleanedCount; + } + + private function deduplicateQueueGroup($redis, $baseName, $keys, $dryRun) + { + $cleanedCount = 0; + $this->line("Processing queue group: {$baseName} (".count($keys).' keys)'); + + // Sort keys to keep the most recent one + usort($keys, function ($a, $b) { + // Prefer keys without timestamps (they're usually the main queue) + $aHasTimestamp = preg_match('/\d{10}/', $a); + $bHasTimestamp = preg_match('/\d{10}/', $b); + + if ($aHasTimestamp && ! $bHasTimestamp) { + return 1; + } + if (! $aHasTimestamp && $bHasTimestamp) { + return -1; + } + + // If both have timestamps, prefer the newer one + if ($aHasTimestamp && $bHasTimestamp) { + preg_match('/(\d{10})/', $a, $aMatches); + preg_match('/(\d{10})/', $b, $bMatches); + + return ($bMatches[1] ?? 0) <=> ($aMatches[1] ?? 0); + } + + return strcmp($a, $b); + }); + + // Keep the first (preferred) key, remove others that are empty or redundant + $keepKey = array_shift($keys); + + foreach ($keys as $redundantKey) { + $type = $redis->command('type', [$redundantKey]); + $shouldDelete = false; + + if ($type === 1) { // LIST type + $length = $redis->command('llen', [$redundantKey]); + if ($length == 0) { + $shouldDelete = true; + } + } elseif ($type === 3) { // SET type + $count = $redis->command('scard', [$redundantKey]); + if ($count == 0) { + $shouldDelete = true; + } + } elseif ($type === 4) { // ZSET type + $count = $redis->command('zcard', [$redundantKey]); + if ($count == 0) { + $shouldDelete = true; + } + } + + if ($shouldDelete) { + if ($dryRun) { + $this->line(" Would delete empty queue: {$redundantKey}"); + } else { + $redis->command('del', [$redundantKey]); + $this->line(" Deleted empty queue: {$redundantKey}"); + } + $cleanedCount++; + } + } + + return $cleanedCount; + } + + private function deduplicateQueueContents($redis, $queueKey, $dryRun) + { + $cleanedCount = 0; + $type = $redis->command('type', [$queueKey]); + + if ($type === 1) { // LIST type - common for job queues + $length = $redis->command('llen', [$queueKey]); + if ($length > 1) { + $items = $redis->command('lrange', [$queueKey, 0, -1]); + $uniqueItems = array_unique($items); + + if (count($uniqueItems) < count($items)) { + $duplicates = count($items) - count($uniqueItems); + + if ($dryRun) { + $this->line(" Would remove {$duplicates} duplicate jobs from queue: {$queueKey}"); + } else { + // Rebuild the list with unique items + $redis->command('del', [$queueKey]); + foreach (array_reverse($uniqueItems) as $item) { + $redis->command('lpush', [$queueKey, $item]); + } + $this->line(" Removed {$duplicates} duplicate jobs from queue: {$queueKey}"); + } + $cleanedCount += $duplicates; + } + } + } + + return $cleanedCount; } } diff --git a/app/Console/Commands/Init.php b/app/Console/Commands/Init.php index a954b10fd..1a7c0911f 100644 --- a/app/Console/Commands/Init.php +++ b/app/Console/Commands/Init.php @@ -36,24 +36,20 @@ class Init extends Command $this->servers = Server::all(); if (! isCloud()) { - $this->send_alive_signal(); + $this->sendAliveSignal(); get_public_ips(); } // Backward compatibility - $this->replace_slash_in_environment_name(); - $this->restore_coolify_db_backup(); - $this->update_user_emails(); + $this->replaceSlashInEnvironmentName(); + $this->restoreCoolifyDbBackup(); + $this->updateUserEmails(); // - $this->update_traefik_labels(); + $this->updateTraefikLabels(); if (! isCloud() || $this->option('force-cloud')) { - $this->cleanup_unused_network_from_coolify_proxy(); - } - if (isCloud()) { - $this->cleanup_unnecessary_dynamic_proxy_configuration(); - } else { - $this->cleanup_in_progress_application_deployments(); + $this->cleanupUnusedNetworkFromCoolifyProxy(); } + $this->call('cleanup:redis'); $this->call('cleanup:stucked-resources'); @@ -66,33 +62,35 @@ class Init extends Command if (isCloud()) { try { + $this->cleanupUnnecessaryDynamicProxyConfiguration(); $this->pullTemplatesFromCDN(); } catch (\Throwable $e) { echo "Could not pull templates from CDN: {$e->getMessage()}\n"; } + + return; } - if (! isCloud()) { - try { - $this->pullTemplatesFromCDN(); - } catch (\Throwable $e) { - echo "Could not pull templates from CDN: {$e->getMessage()}\n"; - } - try { - $localhost = $this->servers->where('id', 0)->first(); - $localhost->setupDynamicProxyConfiguration(); - } catch (\Throwable $e) { - echo "Could not setup dynamic configuration: {$e->getMessage()}\n"; - } - $settings = instanceSettings(); - if (! is_null(config('constants.coolify.autoupdate', null))) { - if (config('constants.coolify.autoupdate') == true) { - echo "Enabling auto-update\n"; - $settings->update(['is_auto_update_enabled' => true]); - } else { - echo "Disabling auto-update\n"; - $settings->update(['is_auto_update_enabled' => false]); - } + try { + $this->cleanupInProgressApplicationDeployments(); + $this->pullTemplatesFromCDN(); + } catch (\Throwable $e) { + echo "Could not pull templates from CDN: {$e->getMessage()}\n"; + } + try { + $localhost = $this->servers->where('id', 0)->first(); + $localhost->setupDynamicProxyConfiguration(); + } catch (\Throwable $e) { + echo "Could not setup dynamic configuration: {$e->getMessage()}\n"; + } + $settings = instanceSettings(); + if (! is_null(config('constants.coolify.autoupdate', null))) { + if (config('constants.coolify.autoupdate') == true) { + echo "Enabling auto-update\n"; + $settings->update(['is_auto_update_enabled' => true]); + } else { + echo "Disabling auto-update\n"; + $settings->update(['is_auto_update_enabled' => false]); } } } @@ -117,7 +115,7 @@ class Init extends Command Artisan::call('optimize'); } - private function update_user_emails() + private function updateUserEmails() { try { User::whereRaw('email ~ \'[A-Z]\'')->get()->each(function (User $user) { @@ -128,7 +126,7 @@ class Init extends Command } } - private function update_traefik_labels() + private function updateTraefikLabels() { try { Server::where('proxy->type', 'TRAEFIK_V2')->update(['proxy->type' => 'TRAEFIK']); @@ -137,7 +135,7 @@ class Init extends Command } } - private function cleanup_unnecessary_dynamic_proxy_configuration() + private function cleanupUnnecessaryDynamicProxyConfiguration() { foreach ($this->servers as $server) { try { @@ -158,7 +156,7 @@ class Init extends Command } } - private function cleanup_unused_network_from_coolify_proxy() + private function cleanupUnusedNetworkFromCoolifyProxy() { foreach ($this->servers as $server) { if (! $server->isFunctional()) { @@ -197,7 +195,7 @@ class Init extends Command } } - private function restore_coolify_db_backup() + private function restoreCoolifyDbBackup() { if (version_compare('4.0.0-beta.179', config('constants.coolify.version'), '<=')) { try { @@ -223,7 +221,7 @@ class Init extends Command } } - private function send_alive_signal() + private function sendAliveSignal() { $id = config('app.id'); $version = config('constants.coolify.version'); @@ -241,7 +239,7 @@ class Init extends Command } } - private function cleanup_in_progress_application_deployments() + private function cleanupInProgressApplicationDeployments() { // Cleanup any failed deployments try { @@ -258,7 +256,7 @@ class Init extends Command } } - private function replace_slash_in_environment_name() + private function replaceSlashInEnvironmentName() { if (version_compare('4.0.0-beta.298', config('constants.coolify.version'), '<=')) { $environments = Environment::all(); diff --git a/app/Jobs/ApplicationDeploymentJob.php b/app/Jobs/ApplicationDeploymentJob.php index c9b7c9992..bc5fab30c 100644 --- a/app/Jobs/ApplicationDeploymentJob.php +++ b/app/Jobs/ApplicationDeploymentJob.php @@ -30,6 +30,7 @@ use Illuminate\Support\Collection; use Illuminate\Support\Sleep; use Illuminate\Support\Str; use RuntimeException; +use Spatie\Url\Url; use Symfony\Component\Yaml\Yaml; use Throwable; use Visus\Cuid2\Cuid2; @@ -471,7 +472,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue } else { $composeFile = $this->application->parse(pull_request_id: $this->pull_request_id, preview_id: data_get($this->preview, 'id')); $this->save_environment_variables(); - if (! is_null($this->env_filename)) { + if (filled($this->env_filename)) { $services = collect(data_get($composeFile, 'services', [])); $services = $services->map(function ($service, $name) { $service['env_file'] = [$this->env_filename]; @@ -480,7 +481,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue }); $composeFile['services'] = $services->toArray(); } - if (is_null($composeFile)) { + if (empty($composeFile)) { $this->application_deployment_queue->addLogEntry('Failed to parse docker-compose file.'); $this->fail('Failed to parse docker-compose file.'); @@ -887,10 +888,6 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue private function save_environment_variables() { $envs = collect([]); - $local_branch = $this->branch; - if ($this->pull_request_id !== 0) { - $local_branch = "pull/{$this->pull_request_id}/head"; - } $sort = $this->application->settings->is_env_sorting_enabled; if ($sort) { $sorted_environment_variables = $this->application->environment_variables->sortBy('key'); @@ -899,6 +896,14 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue $sorted_environment_variables = $this->application->environment_variables->sortBy('id'); $sorted_environment_variables_preview = $this->application->environment_variables_preview->sortBy('id'); } + if ($this->build_pack === 'dockercompose') { + $sorted_environment_variables = $sorted_environment_variables->filter(function ($env) { + return ! str($env->key)->startsWith('SERVICE_FQDN_') && ! str($env->key)->startsWith('SERVICE_URL_'); + }); + $sorted_environment_variables_preview = $sorted_environment_variables_preview->filter(function ($env) { + return ! str($env->key)->startsWith('SERVICE_FQDN_') && ! str($env->key)->startsWith('SERVICE_URL_'); + }); + } $ports = $this->application->main_port(); $coolify_envs = $this->generate_coolify_env_variables(); $coolify_envs->each(function ($item, $key) use ($envs) { @@ -908,17 +913,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue $this->env_filename = '.env'; foreach ($sorted_environment_variables as $env) { - $real_value = $env->real_value; - if ($env->version === '4.0.0-beta.239') { - $real_value = $env->real_value; - } else { - if ($env->is_literal || $env->is_multiline) { - $real_value = '\''.$real_value.'\''; - } else { - $real_value = escapeEnvVariables($env->real_value); - } - } - $envs->push($env->key.'='.$real_value); + $envs->push($env->key.'='.$env->real_value); } // Add PORT if not exists, use the first port as default if ($this->build_pack !== 'dockercompose') { @@ -930,20 +925,28 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue if ($this->application->environment_variables->where('key', 'HOST')->isEmpty()) { $envs->push('HOST=0.0.0.0'); } + + if ($this->build_pack === 'dockercompose') { + $domains = collect(json_decode($this->application->docker_compose_domains)) ?? collect([]); + + // Generate SERVICE_FQDN & SERVICE_URL for dockercompose + foreach ($domains as $forServiceName => $domain) { + $parsedDomain = data_get($domain, 'domain'); + if (filled($parsedDomain)) { + $parsedDomain = str($parsedDomain)->explode(',')->first(); + $coolifyUrl = Url::fromString($parsedDomain); + $coolifyScheme = $coolifyUrl->getScheme(); + $coolifyFqdn = $coolifyUrl->getHost(); + $coolifyUrl = $coolifyUrl->withScheme($coolifyScheme)->withHost($coolifyFqdn)->withPort(null); + $envs->push('SERVICE_URL_'.str($forServiceName)->upper().'='.$coolifyUrl->__toString()); + $envs->push('SERVICE_FQDN_'.str($forServiceName)->upper().'='.$coolifyFqdn); + } + } + } } else { $this->env_filename = ".env-pr-$this->pull_request_id"; foreach ($sorted_environment_variables_preview as $env) { - $real_value = $env->real_value; - if ($env->version === '4.0.0-beta.239') { - $real_value = $env->real_value; - } else { - if ($env->is_literal || $env->is_multiline) { - $real_value = '\''.$real_value.'\''; - } else { - $real_value = escapeEnvVariables($env->real_value); - } - } - $envs->push($env->key.'='.$real_value); + $envs->push($env->key.'='.$env->real_value); } // Add PORT if not exists, use the first port as default if ($this->build_pack !== 'dockercompose') { @@ -956,6 +959,23 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue $envs->push('HOST=0.0.0.0'); } + if ($this->build_pack === 'dockercompose') { + $domains = collect(json_decode(data_get($this->preview, 'docker_compose_domains'))) ?? collect([]); + + // Generate SERVICE_FQDN & SERVICE_URL for dockercompose + foreach ($domains as $forServiceName => $domain) { + $parsedDomain = data_get($domain, 'domain'); + if (filled($parsedDomain)) { + $parsedDomain = str($parsedDomain)->explode(',')->first(); + $coolifyUrl = Url::fromString($parsedDomain); + $coolifyScheme = $coolifyUrl->getScheme(); + $coolifyFqdn = $coolifyUrl->getHost(); + $coolifyUrl = $coolifyUrl->withScheme($coolifyScheme)->withHost($coolifyFqdn)->withPort(null); + $envs->push('SERVICE_URL_'.str($forServiceName)->upper().'='.$coolifyUrl->__toString()); + $envs->push('SERVICE_FQDN_'.str($forServiceName)->upper().'='.$coolifyFqdn); + } + } + } } if ($envs->isEmpty()) { $this->env_filename = null; @@ -1367,9 +1387,16 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue $fqdn = $this->preview->fqdn; } if (isset($fqdn)) { - $this->coolify_variables .= "COOLIFY_FQDN={$fqdn} "; - $url = str($fqdn)->replace('http://', '')->replace('https://', ''); - $this->coolify_variables .= "COOLIFY_URL={$url} "; + $url = Url::fromString($fqdn); + $fqdn = $url->getHost(); + $url = $url->withHost($fqdn)->withPort(null)->__toString(); + if ((int) $this->application->compose_parsing_version >= 3) { + $this->coolify_variables .= "COOLIFY_URL={$url} "; + $this->coolify_variables .= "COOLIFY_FQDN={$fqdn} "; + } else { + $this->coolify_variables .= "COOLIFY_URL={$fqdn} "; + $this->coolify_variables .= "COOLIFY_FQDN={$url} "; + } } if (isset($this->application->git_branch)) { $this->coolify_variables .= "COOLIFY_BRANCH={$this->application->git_branch} "; @@ -1715,10 +1742,6 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue { $this->create_workdir(); $ports = $this->application->main_port(); - $onlyPort = null; - if (count($ports) > 0) { - $onlyPort = $ports[0]; - } $persistent_storages = $this->generate_local_persistent_volumes(); $persistent_file_volumes = $this->application->fileStorages()->get(); $volume_names = $this->generate_local_persistent_volumes_only_volume_names(); @@ -2253,9 +2276,10 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf"); $this->application_deployment_queue->addLogEntry('Building docker image completed.'); } - private function graceful_shutdown_container(string $containerName, int $timeout = 30) + private function graceful_shutdown_container(string $containerName) { try { + $timeout = isDev() ? 1 : 30; $this->execute_remote_command( ["docker stop --time=$timeout $containerName", 'hidden' => true, 'ignore_errors' => true], ["docker rm -f $containerName", 'hidden' => true, 'ignore_errors' => true] diff --git a/app/Livewire/Project/Application/Previews.php b/app/Livewire/Project/Application/Previews.php index b2c1cf8e1..c781c9d8a 100644 --- a/app/Livewire/Project/Application/Previews.php +++ b/app/Livewire/Project/Application/Previews.php @@ -7,7 +7,6 @@ use App\Models\Application; use App\Models\ApplicationPreview; use Illuminate\Support\Collection; use Livewire\Component; -use Spatie\Url\Url; use Visus\Cuid2\Cuid2; class Previews extends Component @@ -87,18 +86,9 @@ class Previews extends Component return; } - $fqdn = generateFqdn($this->application->destination->server, $this->application->uuid); - $url = Url::fromString($fqdn); - $template = $this->application->preview_url_template; - $host = $url->getHost(); - $schema = $url->getScheme(); - $random = new Cuid2; - $preview_fqdn = str_replace('{{random}}', $random, $template); - $preview_fqdn = str_replace('{{domain}}', $host, $preview_fqdn); - $preview_fqdn = str_replace('{{pr_id}}', $preview->pull_request_id, $preview_fqdn); - $preview_fqdn = "$schema://$preview_fqdn"; - $preview->fqdn = $preview_fqdn; - $preview->save(); + $this->application->generate_preview_fqdn($preview->pull_request_id); + $this->application->refresh(); + $this->dispatch('update_links'); $this->dispatch('success', 'Domain generated.'); } diff --git a/app/Livewire/Project/Database/BackupExecutions.php b/app/Livewire/Project/Database/BackupExecutions.php index 3fc721fda..f96ca9a6a 100644 --- a/app/Livewire/Project/Database/BackupExecutions.php +++ b/app/Livewire/Project/Database/BackupExecutions.php @@ -121,8 +121,8 @@ class BackupExecutions extends Component { return view('livewire.project.database.backup-executions', [ 'checkboxes' => [ - ['id' => 'delete_backup_s3', 'label' => 'Delete the selected backup permanently form S3 Storage'], - // ['id' => 'delete_backup_sftp', 'label' => 'Delete the selected backup permanently form SFTP Storage'], + ['id' => 'delete_backup_s3', 'label' => 'Delete the selected backup permanently from S3 Storage'], + // ['id' => 'delete_backup_sftp', 'label' => 'Delete the selected backup permanently from SFTP Storage'], ], ]); } diff --git a/app/Models/ApplicationPreview.php b/app/Models/ApplicationPreview.php index bf2bf05bf..c635f146a 100644 --- a/app/Models/ApplicationPreview.php +++ b/app/Models/ApplicationPreview.php @@ -52,24 +52,38 @@ class ApplicationPreview extends BaseModel public function generate_preview_fqdn_compose() { - $domains = collect(json_decode($this->application->docker_compose_domains)) ?? collect(); - foreach ($domains as $service_name => $domain) { - $domain = data_get($domain, 'domain'); - $url = Url::fromString($domain); - $template = $this->application->preview_url_template; - $host = $url->getHost(); - $schema = $url->getScheme(); - $random = new Cuid2; - $preview_fqdn = str_replace('{{random}}', $random, $template); - $preview_fqdn = str_replace('{{domain}}', $host, $preview_fqdn); - $preview_fqdn = str_replace('{{pr_id}}', $this->pull_request_id, $preview_fqdn); - $preview_fqdn = "$schema://$preview_fqdn"; - $docker_compose_domains = data_get($this, 'docker_compose_domains'); - $docker_compose_domains = json_decode($docker_compose_domains, true); - $docker_compose_domains[$service_name]['domain'] = $preview_fqdn; - $docker_compose_domains = json_encode($docker_compose_domains); - $this->docker_compose_domains = $docker_compose_domains; - $this->save(); + $services = collect(json_decode($this->application->docker_compose_domains)) ?? collect(); + $docker_compose_domains = data_get($this, 'docker_compose_domains'); + $docker_compose_domains = json_decode($docker_compose_domains, true) ?? []; + + foreach ($services as $service_name => $service_config) { + $domain_string = data_get($service_config, 'domain'); + $service_domains = str($domain_string)->explode(',')->map(fn ($d) => trim($d)); + + $preview_domains = []; + foreach ($service_domains as $domain) { + if (empty($domain)) { + continue; + } + + $url = Url::fromString($domain); + $template = $this->application->preview_url_template; + $host = $url->getHost(); + $schema = $url->getScheme(); + $random = new Cuid2; + $preview_fqdn = str_replace('{{random}}', $random, $template); + $preview_fqdn = str_replace('{{domain}}', $host, $preview_fqdn); + $preview_fqdn = str_replace('{{pr_id}}', $this->pull_request_id, $preview_fqdn); + $preview_fqdn = "$schema://$preview_fqdn"; + $preview_domains[] = $preview_fqdn; + } + + if (! empty($preview_domains)) { + $docker_compose_domains[$service_name]['domain'] = implode(',', $preview_domains); + } } + + $this->docker_compose_domains = json_encode($docker_compose_domains); + $this->save(); } } diff --git a/app/Models/EnvironmentVariable.php b/app/Models/EnvironmentVariable.php index 04081fce0..b8bde5c84 100644 --- a/app/Models/EnvironmentVariable.php +++ b/app/Models/EnvironmentVariable.php @@ -118,7 +118,14 @@ class EnvironmentVariable extends BaseModel return null; } - return $this->get_real_environment_variables($this->value, $resource); + $real_value = $this->get_real_environment_variables($this->value, $resource); + if ($this->is_literal || $this->is_multiline) { + $real_value = '\''.$real_value.'\''; + } else { + $real_value = escapeEnvVariables($real_value); + } + + return $real_value; } ); } diff --git a/app/Models/Service.php b/app/Models/Service.php index a9302d7e7..da6c34fbb 100644 --- a/app/Models/Service.php +++ b/app/Models/Service.php @@ -1260,26 +1260,17 @@ class Service extends BaseModel return 3; }); + $envs = collect([]); foreach ($sorted as $env) { - if (version_compare($env->version, '4.0.0-beta.347', '<=')) { - $commands[] = "echo '{$env->key}={$env->real_value}' >> .env"; - } else { - $real_value = $env->real_value; - if ($env->version === '4.0.0-beta.239') { - $real_value = $env->real_value; - } else { - if ($env->is_literal || $env->is_multiline) { - $real_value = '\''.$real_value.'\''; - } else { - $real_value = escapeEnvVariables($env->real_value); - } - } - $commands[] = "echo \"{$env->key}={$real_value}\" >> .env"; - } + $envs->push("{$env->key}={$env->real_value}"); } - if ($sorted->count() === 0) { + if ($envs->count() === 0) { $commands[] = 'touch .env'; + } else { + $envs_base64 = base64_encode($envs->implode("\n")); + $commands[] = "echo '$envs_base64' | base64 -d | tee .env > /dev/null"; } + instant_remote_process($commands, $this->server); } diff --git a/bootstrap/helpers/shared.php b/bootstrap/helpers/shared.php index 9e1aa0a43..00a674eeb 100644 --- a/bootstrap/helpers/shared.php +++ b/bootstrap/helpers/shared.php @@ -599,7 +599,15 @@ function getTopLevelNetworks(Service|Application $resource) try { $yaml = Yaml::parse($resource->docker_compose_raw); } catch (\Exception $e) { - throw new \RuntimeException($e->getMessage()); + // If the docker-compose.yml file is not valid, we will return the network name as the key + $topLevelNetworks = collect([ + $resource->uuid => [ + 'name' => $resource->uuid, + 'external' => true, + ], + ]); + + return $topLevelNetworks->keys(); } $services = data_get($yaml, 'services'); $topLevelNetworks = collect(data_get($yaml, 'networks', [])); @@ -653,9 +661,16 @@ function getTopLevelNetworks(Service|Application $resource) try { $yaml = Yaml::parse($resource->docker_compose_raw); } catch (\Exception $e) { - throw new \RuntimeException($e->getMessage()); + // If the docker-compose.yml file is not valid, we will return the network name as the key + $topLevelNetworks = collect([ + $resource->uuid => [ + 'name' => $resource->uuid, + 'external' => true, + ], + ]); + + return $topLevelNetworks->keys(); } - $server = $resource->destination->server; $topLevelNetworks = collect(data_get($yaml, 'networks', [])); $services = data_get($yaml, 'services'); $definedNetwork = collect([$resource->uuid]); @@ -2931,7 +2946,6 @@ function newParser(Application|Service $resource, int $pull_request_id = 0, ?int } catch (\Exception) { return collect([]); } - $services = data_get($yaml, 'services', collect([])); $topLevel = collect([ 'volumes' => collect(data_get($yaml, 'volumes', [])), @@ -3049,7 +3063,6 @@ function newParser(Application|Service $resource, int $pull_request_id = 0, ?int } } } - // Get magic environments where we need to preset the FQDN if ($key->startsWith('SERVICE_FQDN_')) { // SERVICE_FQDN_APP or SERVICE_FQDN_APP_3000 @@ -3134,6 +3147,9 @@ function newParser(Application|Service $resource, int $pull_request_id = 0, ?int continue; } if ($command->value() === 'FQDN') { + if ($isApplication && $resource->build_pack === 'dockercompose') { + continue; + } $fqdnFor = $key->after('SERVICE_FQDN_')->lower()->value(); if (str($fqdnFor)->contains('_')) { $fqdnFor = str($fqdnFor)->before('_'); @@ -3149,6 +3165,9 @@ function newParser(Application|Service $resource, int $pull_request_id = 0, ?int 'is_preview' => false, ]); } elseif ($command->value() === 'URL') { + if ($isApplication && $resource->build_pack === 'dockercompose') { + continue; + } $fqdnFor = $key->after('SERVICE_URL_')->lower()->value(); if (str($fqdnFor)->contains('_')) { $fqdnFor = str($fqdnFor)->before('_'); @@ -3599,7 +3618,8 @@ function newParser(Application|Service $resource, int $pull_request_id = 0, ?int 'is_required' => $isRequired, ]); // Add the variable to the environment so it will be shown in the deployable compose file - $environment[$parsedKeyValue->value()] = $resource->environment_variables()->where('key', $parsedKeyValue)->where('resourceable_type', get_class($resource))->where('resourceable_id', $resource->id)->first()->value; + // $environment[$parsedKeyValue->value()] = $resource->environment_variables()->where('key', $parsedKeyValue)->where('resourceable_type', get_class($resource))->where('resourceable_id', $resource->id)->first()->real_value; + $environment[$parsedKeyValue->value()] = $value; continue; } @@ -3637,9 +3657,30 @@ function newParser(Application|Service $resource, int $pull_request_id = 0, ?int } if ($isApplication) { - $domains = collect(json_decode($resource->docker_compose_domains)) ?? collect([]); + if ($isPullRequest) { + $preview = $resource->previews()->find($preview_id); + $domains = collect(json_decode(data_get($preview, 'docker_compose_domains'))) ?? collect([]); + } else { + $domains = collect(json_decode($resource->docker_compose_domains)) ?? collect([]); + } $fqdns = data_get($domains, "$serviceName.domain"); - if ($fqdns) { + // Generate SERVICE_FQDN & SERVICE_URL for dockercompose + if ($resource->build_pack === 'dockercompose') { + foreach ($domains as $forServiceName => $domain) { + $parsedDomain = data_get($domain, 'domain'); + if (filled($parsedDomain)) { + $parsedDomain = str($parsedDomain)->explode(',')->first(); + $coolifyUrl = Url::fromString($parsedDomain); + $coolifyScheme = $coolifyUrl->getScheme(); + $coolifyFqdn = $coolifyUrl->getHost(); + $coolifyUrl = $coolifyUrl->withScheme($coolifyScheme)->withHost($coolifyFqdn)->withPort(null); + $coolifyEnvironments->put('SERVICE_URL_'.str($forServiceName)->upper(), $coolifyUrl->__toString()); + $coolifyEnvironments->put('SERVICE_FQDN_'.str($forServiceName)->upper(), $coolifyFqdn); + } + } + } + // If the domain is set, we need to generate the FQDNs for the preview + if (filled($fqdns)) { $fqdns = str($fqdns)->explode(','); if ($isPullRequest) { $preview = $resource->previews()->find($preview_id); @@ -3671,7 +3712,6 @@ function newParser(Application|Service $resource, int $pull_request_id = 0, ?int } } } - $defaultLabels = defaultLabels( id: $resource->id, name: $containerName, @@ -3681,6 +3721,7 @@ function newParser(Application|Service $resource, int $pull_request_id = 0, ?int type: 'application', environment: $resource->environment->name, ); + } elseif ($isService) { if ($savedService->serviceType()) { $fqdns = generateServiceSpecificFqdns($savedService); @@ -3702,10 +3743,13 @@ function newParser(Application|Service $resource, int $pull_request_id = 0, ?int } // Add COOLIFY_FQDN & COOLIFY_URL to environment if (! $isDatabase && $fqdns instanceof Collection && $fqdns->count() > 0) { - $coolifyEnvironments->put('COOLIFY_URL', $fqdns->implode(',')); + $fqdnsWithoutPort = $fqdns->map(function ($fqdn) { + return str($fqdn)->after('://')->before(':')->prepend(str($fqdn)->before('://')->append('://')); + }); + $coolifyEnvironments->put('COOLIFY_URL', $fqdnsWithoutPort->implode(',')); $urls = $fqdns->map(function ($fqdn) { - return str($fqdn)->replace('http://', '')->replace('https://', ''); + return str($fqdn)->replace('http://', '')->replace('https://', '')->before(':'); }); $coolifyEnvironments->put('COOLIFY_FQDN', $urls->implode(',')); } diff --git a/config/constants.php b/config/constants.php index 67e24d80d..8fbc3a6cb 100644 --- a/config/constants.php +++ b/config/constants.php @@ -2,7 +2,7 @@ return [ 'coolify' => [ - 'version' => '4.0.0-beta.420.3', + 'version' => '4.0.0-beta.420.4', 'helper_version' => '1.0.8', 'realtime_version' => '1.0.9', 'self_hosted' => env('SELF_HOSTED', true), diff --git a/resources/views/livewire/project/application/deployment/show.blade.php b/resources/views/livewire/project/application/deployment/show.blade.php index 01edc44e2..0857818d6 100644 --- a/resources/views/livewire/project/application/deployment/show.blade.php +++ b/resources/views/livewire/project/application/deployment/show.blade.php @@ -5,7 +5,7 @@