diff --git a/CLAUDE.md b/CLAUDE.md index 8e398586b..5dc2f7eee 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -73,7 +73,7 @@ ## Key Conventions - PHP 8.4: constructor property promotion, explicit return types, type hints - Always create Form Request classes for validation - Run `vendor/bin/pint --dirty --format agent` before finalizing changes -- Every change must have tests — write or update tests, then run them +- Every change must have tests — write or update tests, then run them. For bug fixes, follow TDD: write a failing test first, then fix the bug (see Test Enforcement below) - Check sibling files for conventions before creating new files ## Git Workflow @@ -231,6 +231,16 @@ # Test Enforcement - Every change must be programmatically tested. Write a new test or update an existing test, then run the affected tests to make sure they pass. - Run the minimum number of tests needed to ensure code quality and speed. Use `php artisan test --compact` with a specific filename or filter. +## Bug Fix Workflow (TDD) + +When fixing a bug, follow this strict test-driven workflow: + +1. **Write a test first** that asserts the correct (expected) behavior — this test should reproduce the bug. +2. **Run the test** and confirm it **fails**. If it passes, the test does not cover the bug — rewrite it. +3. **Fix the bug** in the source code. +4. **Re-run the exact same test without any modifications** and confirm it **passes**. +5. **Never modify the test between steps 2 and 4.** The same test must go from red to green purely from the bug fix. + === laravel/core rules === # Do Things the Laravel Way diff --git a/README.md b/README.md index 73af2a18c..a5aa69343 100644 --- a/README.md +++ b/README.md @@ -90,6 +90,7 @@ ### Big Sponsors * [Logto](https://logto.io?ref=coolify.io) - The better identity infrastructure for developers * [Macarne](https://macarne.com?ref=coolify.io) - Best IP Transit & Carrier Ethernet Solutions for Simplified Network Connectivity * [Mobb](https://vibe.mobb.ai/?ref=coolify.io) - Secure Your AI-Generated Code to Unlock Dev Productivity +* [PetroSky Cloud](https://petrosky.io?ref=coolify.io) - Open source cloud deployment solutions * [PFGLabs](https://pfglabs.com?ref=coolify.io) - Build Real Projects with Golang * [Ramnode](https://ramnode.com/?ref=coolify.io) - High Performance Cloud VPS Hosting * [SaasyKit](https://saasykit.com?ref=coolify.io) - Complete SaaS starter kit for developers diff --git a/app/Actions/Proxy/GetProxyConfiguration.php b/app/Actions/Proxy/GetProxyConfiguration.php index de44b476f..159f12252 100644 --- a/app/Actions/Proxy/GetProxyConfiguration.php +++ b/app/Actions/Proxy/GetProxyConfiguration.php @@ -2,10 +2,12 @@ namespace App\Actions\Proxy; +use App\Enums\ProxyTypes; use App\Models\Server; use App\Services\ProxyDashboardCacheService; use Illuminate\Support\Facades\Log; use Lorisleiva\Actions\Concerns\AsAction; +use Symfony\Component\Yaml\Yaml; class GetProxyConfiguration { @@ -24,6 +26,17 @@ public function handle(Server $server, bool $forceRegenerate = false): string // Primary source: database $proxy_configuration = $server->proxy->get('last_saved_proxy_configuration'); + // Validate stored config matches current proxy type + if (! empty(trim($proxy_configuration ?? ''))) { + if (! $this->configMatchesProxyType($proxyType, $proxy_configuration)) { + Log::warning('Stored proxy config does not match current proxy type, will regenerate', [ + 'server_id' => $server->id, + 'proxy_type' => $proxyType, + ]); + $proxy_configuration = null; + } + } + // Backfill: existing servers may not have DB config yet — read from disk once if (empty(trim($proxy_configuration ?? ''))) { $proxy_configuration = $this->backfillFromDisk($server); @@ -55,6 +68,29 @@ public function handle(Server $server, bool $forceRegenerate = false): string return $proxy_configuration; } + /** + * Check that the stored docker-compose YAML contains the expected service + * for the server's current proxy type. Returns false if the config belongs + * to a different proxy type (e.g. Traefik config on a CADDY server). + */ + private function configMatchesProxyType(string $proxyType, string $configuration): bool + { + try { + $yaml = Yaml::parse($configuration); + $services = data_get($yaml, 'services', []); + + return match ($proxyType) { + ProxyTypes::TRAEFIK->value => isset($services['traefik']), + ProxyTypes::CADDY->value => isset($services['caddy']), + ProxyTypes::NGINX->value => isset($services['nginx']), + default => true, + }; + } catch (\Throwable $e) { + // If YAML is unparseable, don't block — let the existing flow handle it + return true; + } + } + /** * Backfill: read config from disk for servers that predate DB storage. * Stores the result in the database so future reads skip SSH entirely. diff --git a/app/Actions/Server/InstallDocker.php b/app/Actions/Server/InstallDocker.php index 31e582c9b..2e08ec6ad 100644 --- a/app/Actions/Server/InstallDocker.php +++ b/app/Actions/Server/InstallDocker.php @@ -11,11 +11,8 @@ class InstallDocker { use AsAction; - private string $dockerVersion; - public function handle(Server $server) { - $this->dockerVersion = config('constants.docker.minimum_required_version'); $supported_os_type = $server->validateOS(); if (! $supported_os_type) { throw new \Exception('Server OS type is not supported for automated installation. Please install Docker manually before continuing: documentation.'); @@ -118,7 +115,7 @@ public function handle(Server $server) private function getDebianDockerInstallCommand(): string { - return "curl --max-time 300 --retry 3 https://releases.rancher.com/install-docker/{$this->dockerVersion}.sh | sh || curl --max-time 300 --retry 3 https://get.docker.com | sh -s -- --version {$this->dockerVersion} || (". + return 'curl -fsSL https://get.docker.com | sh || ('. '. /etc/os-release && '. 'install -m 0755 -d /etc/apt/keyrings && '. 'curl -fsSL https://download.docker.com/linux/${ID}/gpg -o /etc/apt/keyrings/docker.asc && '. @@ -131,7 +128,7 @@ private function getDebianDockerInstallCommand(): string private function getRhelDockerInstallCommand(): string { - return "curl https://releases.rancher.com/install-docker/{$this->dockerVersion}.sh | sh || curl https://get.docker.com | sh -s -- --version {$this->dockerVersion} || (". + return 'curl -fsSL https://get.docker.com | sh || ('. 'dnf config-manager --add-repo https://download.docker.com/linux/centos/docker-ce.repo && '. 'dnf install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin && '. 'systemctl start docker && '. @@ -141,7 +138,7 @@ private function getRhelDockerInstallCommand(): string private function getSuseDockerInstallCommand(): string { - return "curl https://releases.rancher.com/install-docker/{$this->dockerVersion}.sh | sh || curl https://get.docker.com | sh -s -- --version {$this->dockerVersion} || (". + return 'curl -fsSL https://get.docker.com | sh || ('. 'zypper addrepo https://download.docker.com/linux/sles/docker-ce.repo && '. 'zypper refresh && '. 'zypper install -y --no-confirm docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin && '. @@ -152,10 +149,6 @@ private function getSuseDockerInstallCommand(): string private function getArchDockerInstallCommand(): string { - // Use -Syu to perform full system upgrade before installing Docker - // Partial upgrades (-Sy without -u) are discouraged on Arch Linux - // as they can lead to broken dependencies and system instability - // Use --needed to skip reinstalling packages that are already up-to-date (idempotent) return 'pacman -Syu --noconfirm --needed docker docker-compose && '. 'systemctl enable docker.service && '. 'systemctl start docker.service'; @@ -163,6 +156,6 @@ private function getArchDockerInstallCommand(): string private function getGenericDockerInstallCommand(): string { - return "curl --max-time 300 --retry 3 https://releases.rancher.com/install-docker/{$this->dockerVersion}.sh | sh || curl --max-time 300 --retry 3 https://get.docker.com | sh -s -- --version {$this->dockerVersion}"; + return 'curl -fsSL https://get.docker.com | sh'; } } diff --git a/app/Console/Commands/SyncBunny.php b/app/Console/Commands/SyncBunny.php index 0a98f1dc8..9ac3371e0 100644 --- a/app/Console/Commands/SyncBunny.php +++ b/app/Console/Commands/SyncBunny.php @@ -363,6 +363,162 @@ private function syncReleasesAndVersionsToGitHubRepo(string $versionsLocation, b } } + /** + * Sync install.sh, docker-compose, and env files to GitHub repository via PR + */ + private function syncFilesToGitHubRepo(array $files, bool $nightly = false): bool + { + $envLabel = $nightly ? 'NIGHTLY' : 'PRODUCTION'; + $this->info("Syncing $envLabel files to GitHub repository..."); + try { + $timestamp = time(); + $tmpDir = sys_get_temp_dir().'/coolify-cdn-files-'.$timestamp; + $branchName = 'update-files-'.$timestamp; + + // Clone the repository + $this->info('Cloning coolify-cdn repository...'); + $output = []; + exec('gh repo clone coollabsio/coolify-cdn '.escapeshellarg($tmpDir).' 2>&1', $output, $returnCode); + if ($returnCode !== 0) { + $this->error('Failed to clone repository: '.implode("\n", $output)); + + return false; + } + + // Create feature branch + $this->info('Creating feature branch...'); + $output = []; + exec('cd '.escapeshellarg($tmpDir).' && git checkout -b '.escapeshellarg($branchName).' 2>&1', $output, $returnCode); + if ($returnCode !== 0) { + $this->error('Failed to create branch: '.implode("\n", $output)); + exec('rm -rf '.escapeshellarg($tmpDir)); + + return false; + } + + // Copy each file to its target path in the CDN repo + $copiedFiles = []; + foreach ($files as $sourceFile => $targetPath) { + if (! file_exists($sourceFile)) { + $this->warn("Source file not found, skipping: $sourceFile"); + + continue; + } + + $destPath = "$tmpDir/$targetPath"; + $destDir = dirname($destPath); + + if (! is_dir($destDir)) { + if (! mkdir($destDir, 0755, true)) { + $this->error("Failed to create directory: $destDir"); + exec('rm -rf '.escapeshellarg($tmpDir)); + + return false; + } + } + + if (copy($sourceFile, $destPath) === false) { + $this->error("Failed to copy $sourceFile to $destPath"); + exec('rm -rf '.escapeshellarg($tmpDir)); + + return false; + } + + $copiedFiles[] = $targetPath; + $this->info("Copied: $targetPath"); + } + + if (empty($copiedFiles)) { + $this->warn('No files were copied. Nothing to commit.'); + exec('rm -rf '.escapeshellarg($tmpDir)); + + return true; + } + + // Stage all copied files + $this->info('Staging changes...'); + $output = []; + $stageCmd = 'cd '.escapeshellarg($tmpDir).' && git add '.implode(' ', array_map('escapeshellarg', $copiedFiles)).' 2>&1'; + exec($stageCmd, $output, $returnCode); + if ($returnCode !== 0) { + $this->error('Failed to stage changes: '.implode("\n", $output)); + exec('rm -rf '.escapeshellarg($tmpDir)); + + return false; + } + + // Check for changes + $this->info('Checking for changes...'); + $statusOutput = []; + exec('cd '.escapeshellarg($tmpDir).' && git status --porcelain 2>&1', $statusOutput, $returnCode); + if ($returnCode !== 0) { + $this->error('Failed to check repository status: '.implode("\n", $statusOutput)); + exec('rm -rf '.escapeshellarg($tmpDir)); + + return false; + } + + if (empty(array_filter($statusOutput))) { + $this->info('All files are already up to date. No changes to commit.'); + exec('rm -rf '.escapeshellarg($tmpDir)); + + return true; + } + + // Commit changes + $commitMessage = "Update $envLabel files (install.sh, docker-compose, env) - ".date('Y-m-d H:i:s'); + $output = []; + exec('cd '.escapeshellarg($tmpDir).' && git commit -m '.escapeshellarg($commitMessage).' 2>&1', $output, $returnCode); + if ($returnCode !== 0) { + $this->error('Failed to commit changes: '.implode("\n", $output)); + exec('rm -rf '.escapeshellarg($tmpDir)); + + return false; + } + + // Push to remote + $this->info('Pushing branch to remote...'); + $output = []; + exec('cd '.escapeshellarg($tmpDir).' && git push origin '.escapeshellarg($branchName).' 2>&1', $output, $returnCode); + if ($returnCode !== 0) { + $this->error('Failed to push branch: '.implode("\n", $output)); + exec('rm -rf '.escapeshellarg($tmpDir)); + + return false; + } + + // Create pull request + $this->info('Creating pull request...'); + $prTitle = "Update $envLabel files - ".date('Y-m-d H:i:s'); + $fileList = implode("\n- ", $copiedFiles); + $prBody = "Automated update of $envLabel files:\n- $fileList"; + $prCommand = 'gh pr create --repo coollabsio/coolify-cdn --title '.escapeshellarg($prTitle).' --body '.escapeshellarg($prBody).' --base main --head '.escapeshellarg($branchName).' 2>&1'; + $output = []; + exec($prCommand, $output, $returnCode); + + // Clean up + exec('rm -rf '.escapeshellarg($tmpDir)); + + if ($returnCode !== 0) { + $this->error('Failed to create PR: '.implode("\n", $output)); + + return false; + } + + $this->info('Pull request created successfully!'); + if (! empty($output)) { + $this->info('PR URL: '.implode("\n", $output)); + } + $this->info('Files synced: '.count($copiedFiles)); + + return true; + } catch (\Throwable $e) { + $this->error('Error syncing files to GitHub: '.$e->getMessage()); + + return false; + } + } + /** * Sync versions.json to GitHub repository via PR */ @@ -581,11 +737,130 @@ public function handle() $versions_location = "$parent_dir/other/nightly/$versions"; } if (! $only_template && ! $only_version && ! $only_github_releases && ! $only_github_versions) { + $envLabel = $nightly ? 'NIGHTLY' : 'PRODUCTION'; + $this->info("About to sync $envLabel files to BunnyCDN and create a GitHub PR for coolify-cdn."); + $this->newLine(); + + // Build file mapping for diff if ($nightly) { - $this->info('About to sync files NIGHTLY (docker-compose.prod.yaml, upgrade.sh, install.sh, etc) to BunnyCDN.'); + $fileMapping = [ + $compose_file_location => 'docker/nightly/docker-compose.yml', + $compose_file_prod_location => 'docker/nightly/docker-compose.prod.yml', + $production_env_location => 'environment/nightly/.env.production', + $upgrade_script_location => 'scripts/nightly/upgrade.sh', + $install_script_location => 'scripts/nightly/install.sh', + ]; } else { - $this->info('About to sync files PRODUCTION (docker-compose.yml, docker-compose.prod.yml, upgrade.sh, install.sh, etc) to BunnyCDN.'); + $fileMapping = [ + $compose_file_location => 'docker/docker-compose.yml', + $compose_file_prod_location => 'docker/docker-compose.prod.yml', + $production_env_location => 'environment/.env.production', + $upgrade_script_location => 'scripts/upgrade.sh', + $install_script_location => 'scripts/install.sh', + ]; } + + // BunnyCDN file mapping (local file => CDN URL path) + $bunnyFileMapping = [ + $compose_file_location => "$bunny_cdn/$bunny_cdn_path/$compose_file", + $compose_file_prod_location => "$bunny_cdn/$bunny_cdn_path/$compose_file_prod", + $production_env_location => "$bunny_cdn/$bunny_cdn_path/$production_env", + $upgrade_script_location => "$bunny_cdn/$bunny_cdn_path/$upgrade_script", + $install_script_location => "$bunny_cdn/$bunny_cdn_path/$install_script", + ]; + + $diffTmpDir = sys_get_temp_dir().'/coolify-cdn-diff-'.time(); + @mkdir($diffTmpDir, 0755, true); + $hasChanges = false; + + // Diff against BunnyCDN + $this->info('Fetching files from BunnyCDN to compare...'); + foreach ($bunnyFileMapping as $localFile => $cdnUrl) { + if (! file_exists($localFile)) { + $this->warn('Local file not found: '.$localFile); + + continue; + } + + $fileName = basename($cdnUrl); + $remoteTmp = "$diffTmpDir/bunny-$fileName"; + + try { + $response = Http::timeout(10)->get($cdnUrl); + if ($response->successful()) { + file_put_contents($remoteTmp, $response->body()); + $diffOutput = []; + exec('diff -u '.escapeshellarg($remoteTmp).' '.escapeshellarg($localFile).' 2>&1', $diffOutput, $diffCode); + if ($diffCode !== 0) { + $hasChanges = true; + $this->newLine(); + $this->info("--- BunnyCDN: $bunny_cdn_path/$fileName"); + $this->info("+++ Local: $fileName"); + foreach ($diffOutput as $line) { + if (str_starts_with($line, '---') || str_starts_with($line, '+++')) { + continue; + } + $this->line($line); + } + } + } else { + $this->info("NEW on BunnyCDN: $bunny_cdn_path/$fileName (HTTP {$response->status()})"); + $hasChanges = true; + } + } catch (\Throwable $e) { + $this->warn("Could not fetch $cdnUrl: {$e->getMessage()}"); + } + } + + // Diff against GitHub coolify-cdn repo + $this->newLine(); + $this->info('Fetching coolify-cdn repo to compare...'); + $output = []; + exec('gh repo clone coollabsio/coolify-cdn '.escapeshellarg("$diffTmpDir/repo").' -- --depth 1 2>&1', $output, $returnCode); + + if ($returnCode === 0) { + foreach ($fileMapping as $localFile => $cdnPath) { + $remotePath = "$diffTmpDir/repo/$cdnPath"; + if (! file_exists($localFile)) { + continue; + } + if (! file_exists($remotePath)) { + $this->info("NEW on GitHub: $cdnPath (does not exist in coolify-cdn yet)"); + $hasChanges = true; + + continue; + } + + $diffOutput = []; + exec('diff -u '.escapeshellarg($remotePath).' '.escapeshellarg($localFile).' 2>&1', $diffOutput, $diffCode); + if ($diffCode !== 0) { + $hasChanges = true; + $this->newLine(); + $this->info("--- GitHub: $cdnPath"); + $this->info("+++ Local: $cdnPath"); + foreach ($diffOutput as $line) { + if (str_starts_with($line, '---') || str_starts_with($line, '+++')) { + continue; + } + $this->line($line); + } + } + } + } else { + $this->warn('Could not fetch coolify-cdn repo for diff.'); + } + + exec('rm -rf '.escapeshellarg($diffTmpDir)); + + if (! $hasChanges) { + $this->newLine(); + $this->info('No differences found. All files are already up to date.'); + + return; + } + + $this->newLine(); + $confirmed = confirm('Are you sure you want to sync?'); if (! $confirmed) { return; @@ -692,7 +967,34 @@ public function handle() $pool->purge("$bunny_cdn/$bunny_cdn_path/$upgrade_script"), $pool->purge("$bunny_cdn/$bunny_cdn_path/$install_script"), ]); - $this->info('All files uploaded & purged...'); + $this->info('All files uploaded & purged to BunnyCDN.'); + $this->newLine(); + + // Sync files to GitHub CDN repository via PR + $this->info('Creating GitHub PR for coolify-cdn repository...'); + if ($nightly) { + $files = [ + $compose_file_location => 'docker/nightly/docker-compose.yml', + $compose_file_prod_location => 'docker/nightly/docker-compose.prod.yml', + $production_env_location => 'environment/nightly/.env.production', + $upgrade_script_location => 'scripts/nightly/upgrade.sh', + $install_script_location => 'scripts/nightly/install.sh', + ]; + } else { + $files = [ + $compose_file_location => 'docker/docker-compose.yml', + $compose_file_prod_location => 'docker/docker-compose.prod.yml', + $production_env_location => 'environment/.env.production', + $upgrade_script_location => 'scripts/upgrade.sh', + $install_script_location => 'scripts/install.sh', + ]; + } + + $githubSuccess = $this->syncFilesToGitHubRepo($files, $nightly); + $this->newLine(); + $this->info('=== Summary ==='); + $this->info('BunnyCDN sync: Complete'); + $this->info('GitHub PR: '.($githubSuccess ? 'Created' : 'Failed')); } catch (\Throwable $e) { $this->error('Error: '.$e->getMessage()); } diff --git a/app/Http/Controllers/Api/HetznerController.php b/app/Http/Controllers/Api/HetznerController.php index 2645c2df1..ed91b4475 100644 --- a/app/Http/Controllers/Api/HetznerController.php +++ b/app/Http/Controllers/Api/HetznerController.php @@ -586,7 +586,8 @@ public function createServer(Request $request) } // Check server limit - if (Team::serverLimitReached()) { + $team = Team::find($teamId); + if (Team::serverLimitReached($team)) { return response()->json(['message' => 'Server limit reached for your subscription.'], 400); } diff --git a/app/Http/Controllers/Api/ServersController.php b/app/Http/Controllers/Api/ServersController.php index da94521a8..2ef95ce8b 100644 --- a/app/Http/Controllers/Api/ServersController.php +++ b/app/Http/Controllers/Api/ServersController.php @@ -290,7 +290,11 @@ public function domains_by_server(Request $request) if (is_null($teamId)) { return invalidTokenResponse(); } - $uuid = $request->get('uuid'); + $server = ModelsServer::whereTeamId($teamId)->whereUuid($request->uuid)->first(); + if (is_null($server)) { + return response()->json(['message' => 'Server not found.'], 404); + } + $uuid = $request->query('uuid'); if ($uuid) { $application = Application::ownedByCurrentTeamAPI($teamId)->where('uuid', $uuid)->first(); if (! $application) { @@ -301,7 +305,9 @@ public function domains_by_server(Request $request) } $projects = Project::where('team_id', $teamId)->get(); $domains = collect(); - $applications = $projects->pluck('applications')->flatten(); + $applications = $projects->pluck('applications')->flatten()->filter(function ($application) use ($server) { + return $application->destination?->server?->id === $server->id; + }); $settings = instanceSettings(); if ($applications->count() > 0) { foreach ($applications as $application) { @@ -341,7 +347,9 @@ public function domains_by_server(Request $request) } } } - $services = $projects->pluck('services')->flatten(); + $services = $projects->pluck('services')->flatten()->filter(function ($service) use ($server) { + return $service->server_id === $server->id; + }); if ($services->count() > 0) { foreach ($services as $service) { $service_applications = $service->applications; @@ -354,7 +362,8 @@ public function domains_by_server(Request $request) })->filter(function (Stringable $fqdn) { return $fqdn->isNotEmpty(); }); - if ($ip === 'host.docker.internal') { + $serviceIp = $server->ip; + if ($serviceIp === 'host.docker.internal') { if ($settings->public_ipv4) { $domains->push([ 'domain' => $fqdn, @@ -370,13 +379,13 @@ public function domains_by_server(Request $request) if (! $settings->public_ipv4 && ! $settings->public_ipv6) { $domains->push([ 'domain' => $fqdn, - 'ip' => $ip, + 'ip' => $serviceIp, ]); } } else { $domains->push([ 'domain' => $fqdn, - 'ip' => $ip, + 'ip' => $serviceIp, ]); } } diff --git a/app/Jobs/ApplicationDeploymentJob.php b/app/Jobs/ApplicationDeploymentJob.php index f4ec4abda..00eb05588 100644 --- a/app/Jobs/ApplicationDeploymentJob.php +++ b/app/Jobs/ApplicationDeploymentJob.php @@ -1370,6 +1370,22 @@ private function generate_runtime_environment_variables() foreach ($runtime_environment_variables_preview as $env) { $envs->push($env->key.'='.$env->real_value); } + + // Fall back to production env vars for keys not overridden by preview vars, + // but only when preview vars are configured. This ensures variables like + // DB_PASSWORD that are only set for production will be available in the + // preview .env file (fixing ${VAR} interpolation in docker-compose YAML), + // while avoiding leaking production values when previews aren't configured. + if ($runtime_environment_variables_preview->isNotEmpty()) { + $previewKeys = $runtime_environment_variables_preview->pluck('key')->toArray(); + $fallback_production_vars = $sorted_environment_variables->filter(function ($env) use ($previewKeys) { + return $env->is_runtime && ! in_array($env->key, $previewKeys); + }); + foreach ($fallback_production_vars as $env) { + $envs->push($env->key.'='.$env->real_value); + } + } + // Add PORT if not exists, use the first port as default if ($this->build_pack !== 'dockercompose') { if ($this->application->environment_variables_preview->where('key', 'PORT')->isEmpty()) { diff --git a/app/Jobs/DatabaseBackupJob.php b/app/Jobs/DatabaseBackupJob.php index b55c324be..041d31bad 100644 --- a/app/Jobs/DatabaseBackupJob.php +++ b/app/Jobs/DatabaseBackupJob.php @@ -399,7 +399,15 @@ public function handle(): void 's3_uploaded' => null, ]); } - $this->team?->notify(new BackupFailed($this->backup, $this->database, $this->error_output ?? $this->backup_output ?? $e->getMessage(), $database)); + try { + $this->team?->notify(new BackupFailed($this->backup, $this->database, $this->error_output ?? $this->backup_output ?? $e->getMessage(), $database)); + } catch (\Throwable $notifyException) { + Log::channel('scheduled-errors')->warning('Failed to send backup failure notification', [ + 'backup_id' => $this->backup->uuid, + 'database' => $database, + 'error' => $notifyException->getMessage(), + ]); + } continue; } @@ -439,11 +447,20 @@ public function handle(): void 'local_storage_deleted' => $localStorageDeleted, ]); - // Send appropriate notification - if ($s3UploadError) { - $this->team->notify(new BackupSuccessWithS3Warning($this->backup, $this->database, $database, $s3UploadError)); - } else { - $this->team->notify(new BackupSuccess($this->backup, $this->database, $database)); + // Send appropriate notification (wrapped in try-catch so notification + // failures never affect backup status — see GitHub issue #9088) + try { + if ($s3UploadError) { + $this->team->notify(new BackupSuccessWithS3Warning($this->backup, $this->database, $database, $s3UploadError)); + } else { + $this->team->notify(new BackupSuccess($this->backup, $this->database, $database)); + } + } catch (\Throwable $e) { + Log::channel('scheduled-errors')->warning('Failed to send backup success notification', [ + 'backup_id' => $this->backup->uuid, + 'database' => $database, + 'error' => $e->getMessage(), + ]); } } } @@ -710,20 +727,32 @@ public function failed(?Throwable $exception): void $log = ScheduledDatabaseBackupExecution::where('uuid', $this->backup_log_uuid)->first(); if ($log) { - $log->update([ - 'status' => 'failed', - 'message' => 'Job permanently failed after '.$this->attempts().' attempts: '.($exception?->getMessage() ?? 'Unknown error'), - 'size' => 0, - 'filename' => null, - 'finished_at' => Carbon::now(), - ]); + // Don't overwrite a successful backup status — a post-backup error + // (e.g. notification failure) should not retroactively mark the backup + // as failed (see GitHub issue #9088) + if ($log->status !== 'success') { + $log->update([ + 'status' => 'failed', + 'message' => 'Job permanently failed after '.$this->attempts().' attempts: '.($exception?->getMessage() ?? 'Unknown error'), + 'size' => 0, + 'filename' => null, + 'finished_at' => Carbon::now(), + ]); + } } - // Notify team about permanent failure - if ($this->team) { + // Notify team about permanent failure (only if backup didn't already succeed) + if ($this->team && $log?->status !== 'success') { $databaseName = $log?->database_name ?? 'unknown'; $output = $this->backup_output ?? $exception?->getMessage() ?? 'Unknown error'; - $this->team->notify(new BackupFailed($this->backup, $this->database, $output, $databaseName)); + try { + $this->team->notify(new BackupFailed($this->backup, $this->database, $output, $databaseName)); + } catch (\Throwable $e) { + Log::channel('scheduled-errors')->warning('Failed to send backup permanent failure notification', [ + 'backup_id' => $this->backup->uuid, + 'error' => $e->getMessage(), + ]); + } } } } diff --git a/app/Jobs/StripeProcessJob.php b/app/Jobs/StripeProcessJob.php index f5d52f29c..3485ffe32 100644 --- a/app/Jobs/StripeProcessJob.php +++ b/app/Jobs/StripeProcessJob.php @@ -73,25 +73,15 @@ public function handle(): void // send_internal_notification("User {$userId} is not an admin or owner of team {$team->id}, customerid: {$customerId}, subscriptionid: {$subscriptionId}."); throw new \RuntimeException("User {$userId} is not an admin or owner of team {$team->id}, customerid: {$customerId}, subscriptionid: {$subscriptionId}."); } - $subscription = Subscription::where('team_id', $teamId)->first(); - if ($subscription) { - // send_internal_notification('Old subscription activated for team: '.$teamId); - $subscription->update([ + Subscription::updateOrCreate( + ['team_id' => $teamId], + [ 'stripe_subscription_id' => $subscriptionId, 'stripe_customer_id' => $customerId, 'stripe_invoice_paid' => true, 'stripe_past_due' => false, - ]); - } else { - // send_internal_notification('New subscription for team: '.$teamId); - Subscription::create([ - 'team_id' => $teamId, - 'stripe_subscription_id' => $subscriptionId, - 'stripe_customer_id' => $customerId, - 'stripe_invoice_paid' => true, - 'stripe_past_due' => false, - ]); - } + ] + ); break; case 'invoice.paid': $customerId = data_get($data, 'customer'); @@ -227,18 +217,14 @@ public function handle(): void // send_internal_notification("User {$userId} is not an admin or owner of team {$team->id}, customerid: {$customerId}."); throw new \RuntimeException("User {$userId} is not an admin or owner of team {$team->id}, customerid: {$customerId}."); } - $subscription = Subscription::where('team_id', $teamId)->first(); - if ($subscription) { - // send_internal_notification("Subscription already exists for team: {$teamId}"); - throw new \RuntimeException("Subscription already exists for team: {$teamId}"); - } else { - Subscription::create([ - 'team_id' => $teamId, + Subscription::updateOrCreate( + ['team_id' => $teamId], + [ 'stripe_subscription_id' => $subscriptionId, 'stripe_customer_id' => $customerId, 'stripe_invoice_paid' => false, - ]); - } + ] + ); break; case 'customer.subscription.updated': $teamId = data_get($data, 'metadata.team_id'); @@ -254,20 +240,19 @@ public function handle(): void $subscription = Subscription::where('stripe_customer_id', $customerId)->first(); if (! $subscription) { if ($status === 'incomplete_expired') { - // send_internal_notification('Subscription incomplete expired'); throw new \RuntimeException('Subscription incomplete expired'); } - if ($teamId) { - $subscription = Subscription::create([ - 'team_id' => $teamId, + if (! $teamId) { + throw new \RuntimeException('No subscription and team id found'); + } + $subscription = Subscription::firstOrCreate( + ['team_id' => $teamId], + [ 'stripe_subscription_id' => $subscriptionId, 'stripe_customer_id' => $customerId, 'stripe_invoice_paid' => false, - ]); - } else { - // send_internal_notification('No subscription and team id found'); - throw new \RuntimeException('No subscription and team id found'); - } + ] + ); } $cancelAtPeriodEnd = data_get($data, 'cancel_at_period_end'); $feedback = data_get($data, 'cancellation_details.feedback'); diff --git a/app/Livewire/ActivityMonitor.php b/app/Livewire/ActivityMonitor.php index 370ff1eaa..85ba60c33 100644 --- a/app/Livewire/ActivityMonitor.php +++ b/app/Livewire/ActivityMonitor.php @@ -55,7 +55,18 @@ public function hydrateActivity() return; } - $this->activity = Activity::find($this->activityId); + $activity = Activity::find($this->activityId); + + if ($activity) { + $teamId = data_get($activity, 'properties.team_id'); + if ($teamId && $teamId !== currentTeam()?->id) { + $this->activity = null; + + return; + } + } + + $this->activity = $activity; } public function updatedActivityId($value) diff --git a/app/Livewire/Subscription/PricingPlans.php b/app/Livewire/Subscription/PricingPlans.php index 6b2d3fb36..6e1b85404 100644 --- a/app/Livewire/Subscription/PricingPlans.php +++ b/app/Livewire/Subscription/PricingPlans.php @@ -11,6 +11,12 @@ class PricingPlans extends Component { public function subscribeStripe($type) { + if (currentTeam()->subscription?->stripe_invoice_paid) { + $this->dispatch('error', 'Team already has an active subscription.'); + + return; + } + Stripe::setApiKey(config('subscription.stripe_api_key')); $priceId = match ($type) { diff --git a/app/Models/EnvironmentVariable.php b/app/Models/EnvironmentVariable.php index cf60d5ab5..5acd4c1e4 100644 --- a/app/Models/EnvironmentVariable.php +++ b/app/Models/EnvironmentVariable.php @@ -32,6 +32,11 @@ )] class EnvironmentVariable extends BaseModel { + protected $attributes = [ + 'is_runtime' => true, + 'is_buildtime' => true, + ]; + protected $fillable = [ // Core identification 'key', diff --git a/app/Models/Server.php b/app/Models/Server.php index 527c744a5..ce877bd20 100644 --- a/app/Models/Server.php +++ b/app/Models/Server.php @@ -1471,6 +1471,9 @@ public function changeProxy(string $proxyType, bool $async = true) if ($validProxyTypes->contains(str($proxyType)->lower())) { $this->proxy->set('type', str($proxyType)->upper()); $this->proxy->set('status', 'exited'); + $this->proxy->set('last_saved_proxy_configuration', null); + $this->proxy->set('last_saved_settings', null); + $this->proxy->set('last_applied_settings', null); $this->save(); if ($this->proxySet()) { if ($async) { diff --git a/app/Models/Team.php b/app/Models/Team.php index 10b22b4e1..5a7b377b6 100644 --- a/app/Models/Team.php +++ b/app/Models/Team.php @@ -89,10 +89,13 @@ protected static function booted() }); } - public static function serverLimitReached() + public static function serverLimitReached(?Team $team = null) { - $serverLimit = Team::serverLimit(); - $team = currentTeam(); + $team = $team ?? currentTeam(); + if (! $team) { + return true; + } + $serverLimit = Team::serverLimit($team); $servers = $team->servers->count(); return $servers >= $serverLimit; @@ -109,19 +112,23 @@ public function subscriptionPastOverDue() public function serverOverflow() { - if ($this->serverLimit() < $this->servers->count()) { + if (Team::serverLimit($this) < $this->servers->count()) { return true; } return false; } - public static function serverLimit() + public static function serverLimit(?Team $team = null) { - if (currentTeam()->id === 0 && isDev()) { + $team = $team ?? currentTeam(); + if (! $team) { + return 0; + } + if ($team->id === 0 && isDev()) { return 9999999; } - $team = Team::find(currentTeam()->id); + $team = Team::find($team->id); if (! $team) { return 0; } diff --git a/app/Rules/ValidHostname.php b/app/Rules/ValidHostname.php index b6b2b8d32..89b68663b 100644 --- a/app/Rules/ValidHostname.php +++ b/app/Rules/ValidHostname.php @@ -62,12 +62,15 @@ public function validate(string $attribute, mixed $value, Closure $fail): void // Ignore errors when facades are not available (e.g., in unit tests) } - $fail('The :attribute contains invalid characters. Only lowercase letters (a-z), numbers (0-9), hyphens (-), and dots (.) are allowed.'); + $fail('The :attribute contains invalid characters. Only letters (a-z, A-Z), numbers (0-9), hyphens (-), and dots (.) are allowed.'); return; } } + // Normalize to lowercase for validation (RFC 1123 hostnames are case-insensitive) + $hostname = strtolower($hostname); + // Additional validation: hostname should not start or end with a dot if (str_starts_with($hostname, '.') || str_ends_with($hostname, '.')) { $fail('The :attribute cannot start or end with a dot.'); @@ -100,9 +103,9 @@ public function validate(string $attribute, mixed $value, Closure $fail): void return; } - // Check if label contains only valid characters (lowercase letters, digits, hyphens) + // Check if label contains only valid characters (letters, digits, hyphens) if (! preg_match('/^[a-z0-9-]+$/', $label)) { - $fail('The :attribute contains invalid characters. Only lowercase letters (a-z), numbers (0-9), hyphens (-), and dots (.) are allowed.'); + $fail('The :attribute contains invalid characters. Only letters (a-z, A-Z), numbers (0-9), hyphens (-), and dots (.) are allowed.'); return; } diff --git a/app/Support/ValidationPatterns.php b/app/Support/ValidationPatterns.php index fdf2b12a6..7b8251729 100644 --- a/app/Support/ValidationPatterns.php +++ b/app/Support/ValidationPatterns.php @@ -10,7 +10,7 @@ class ValidationPatterns /** * Pattern for names excluding all dangerous characters */ - public const NAME_PATTERN = '/^[\p{L}\p{M}\p{N}\s\-_.@\/&]+$/u'; + public const NAME_PATTERN = '/^[\p{L}\p{M}\p{N}\s\-_.@\/&()#,:+]+$/u'; /** * Pattern for descriptions excluding all dangerous characters with some additional allowed characters @@ -96,7 +96,7 @@ public static function descriptionRules(bool $required = false, int $maxLength = public static function nameMessages(): array { return [ - 'name.regex' => 'The name may only contain letters (including Unicode), numbers, spaces, and these characters: - _ . / @ &', + 'name.regex' => 'The name may only contain letters (including Unicode), numbers, spaces, and these characters: - _ . / @ & ( ) # , : +', 'name.min' => 'The name must be at least :min characters.', 'name.max' => 'The name may not be greater than :max characters.', ]; diff --git a/bootstrap/helpers/parsers.php b/bootstrap/helpers/parsers.php index cd4928d63..4ca693fcb 100644 --- a/bootstrap/helpers/parsers.php +++ b/bootstrap/helpers/parsers.php @@ -990,16 +990,17 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int } if ($key->value() === $parsedValue->value()) { // Simple variable reference (e.g. DATABASE_URL: ${DATABASE_URL}) - // Use firstOrCreate to avoid overwriting user-saved values on redeploy - $envVar = $resource->environment_variables()->firstOrCreate([ + // Ensure the variable exists in DB for .env generation and UI display + $resource->environment_variables()->firstOrCreate([ 'key' => $key, 'resourceable_type' => get_class($resource), 'resourceable_id' => $resource->id, ], [ 'is_preview' => false, ]); - // Add the variable to the environment using the saved DB value - $environment[$key->value()] = $envVar->value; + // Keep the ${VAR} reference in compose — Docker Compose resolves from .env at deploy time. + // Do NOT replace with DB value: if user updates env var without re-parsing compose, + // a stale resolved value in environment: would override the correct .env value. } else { if ($value->startsWith('$')) { $isRequired = false; @@ -2341,8 +2342,8 @@ function serviceParser(Service $resource): Collection } if ($key->value() === $parsedValue->value()) { // Simple variable reference (e.g. DATABASE_URL: ${DATABASE_URL}) - // Use firstOrCreate to avoid overwriting user-saved values on redeploy - $envVar = $resource->environment_variables()->firstOrCreate([ + // Ensure the variable exists in DB for .env generation and UI display + $resource->environment_variables()->firstOrCreate([ 'key' => $key, 'resourceable_type' => get_class($resource), 'resourceable_id' => $resource->id, @@ -2350,8 +2351,9 @@ function serviceParser(Service $resource): Collection 'is_preview' => false, 'comment' => $envComments[$originalKey] ?? null, ]); - // Add the variable to the environment using the saved DB value - $environment[$key->value()] = $envVar->value; + // Keep the ${VAR} reference in compose — Docker Compose resolves from .env at deploy time. + // Do NOT replace with DB value: if user updates env var without re-parsing compose, + // a stale resolved value in environment: would override the correct .env value. } else { if ($value->startsWith('$')) { $isRequired = false; diff --git a/bootstrap/helpers/shared.php b/bootstrap/helpers/shared.php index ce9ab5283..a8cffcaff 100644 --- a/bootstrap/helpers/shared.php +++ b/bootstrap/helpers/shared.php @@ -341,7 +341,16 @@ function generate_application_name(string $git_repository, string $git_branch, ? $repo_name = str_contains($git_repository, '/') ? last(explode('/', $git_repository)) : $git_repository; - return Str::kebab("$repo_name:$git_branch-$cuid"); + $name = Str::kebab("$repo_name:$git_branch-$cuid"); + + // Strip characters not allowed by NAME_PATTERN + $name = preg_replace('/[^\p{L}\p{M}\p{N}\s\-_.@\/&()#,:+]+/u', '', $name); + + if (empty($name) || mb_strlen($name) < 3) { + return generate_random_name($cuid); + } + + return $name; } /** diff --git a/config/constants.php b/config/constants.php index 803a0a0bd..828493208 100644 --- a/config/constants.php +++ b/config/constants.php @@ -2,7 +2,7 @@ return [ 'coolify' => [ - 'version' => '4.0.0-beta.469', + 'version' => '4.0.0-beta.471', 'helper_version' => '1.0.12', 'realtime_version' => '1.0.11', 'self_hosted' => env('SELF_HOSTED', true), diff --git a/other/nightly/docker-compose.prod.yml b/other/nightly/docker-compose.prod.yml index d42047245..0bd4ae2dd 100644 --- a/other/nightly/docker-compose.prod.yml +++ b/other/nightly/docker-compose.prod.yml @@ -60,7 +60,7 @@ services: retries: 10 timeout: 2s soketi: - image: '${REGISTRY_URL:-ghcr.io}/coollabsio/coolify-realtime:1.0.10' + image: '${REGISTRY_URL:-ghcr.io}/coollabsio/coolify-realtime:1.0.11' ports: - "${SOKETI_PORT:-6001}:6001" - "6002:6002" diff --git a/other/nightly/docker-compose.windows.yml b/other/nightly/docker-compose.windows.yml index bf1f94af0..ca233356a 100644 --- a/other/nightly/docker-compose.windows.yml +++ b/other/nightly/docker-compose.windows.yml @@ -79,7 +79,7 @@ services: retries: 10 timeout: 2s redis: - image: redis:alpine + image: redis:7-alpine pull_policy: always container_name: coolify-redis restart: always diff --git a/other/nightly/docker-compose.yml b/other/nightly/docker-compose.yml index 68d0f0744..0fd3dda07 100644 --- a/other/nightly/docker-compose.yml +++ b/other/nightly/docker-compose.yml @@ -4,7 +4,7 @@ services: restart: always working_dir: /var/www/html extra_hosts: - - 'host.docker.internal:host-gateway' + - host.docker.internal:host-gateway networks: - coolify depends_on: @@ -18,7 +18,7 @@ services: networks: - coolify redis: - image: redis:alpine + image: redis:7-alpine container_name: coolify-redis restart: always networks: @@ -26,7 +26,7 @@ services: soketi: container_name: coolify-realtime extra_hosts: - - 'host.docker.internal:host-gateway' + - host.docker.internal:host-gateway restart: always networks: - coolify diff --git a/other/nightly/install.sh b/other/nightly/install.sh index 921ba6a92..09406118c 100755 --- a/other/nightly/install.sh +++ b/other/nightly/install.sh @@ -20,7 +20,7 @@ DATE=$(date +"%Y%m%d-%H%M%S") OS_TYPE=$(grep -w "ID" /etc/os-release | cut -d "=" -f 2 | tr -d '"') ENV_FILE="/data/coolify/source/.env" -DOCKER_VERSION="27.0" +DOCKER_VERSION="latest" # TODO: Ask for a user CURRENT_USER=$USER @@ -499,13 +499,10 @@ fi install_docker() { set +e - curl -s https://releases.rancher.com/install-docker/${DOCKER_VERSION}.sh | sh 2>&1 || true + curl -fsSL https://get.docker.com | sh 2>&1 || true if ! [ -x "$(command -v docker)" ]; then - curl -s https://get.docker.com | sh -s -- --version ${DOCKER_VERSION} 2>&1 - if ! [ -x "$(command -v docker)" ]; then - echo "Automated Docker installation failed. Trying manual installation." - install_docker_manually - fi + echo "Automated Docker installation failed. Trying manual installation." + install_docker_manually fi set -e } @@ -548,16 +545,6 @@ if ! [ -x "$(command -v docker)" ]; then echo " - Docker is not installed. Installing Docker. It may take a while." getAJoke case "$OS_TYPE" in - "almalinux") - dnf config-manager --add-repo=https://download.docker.com/linux/centos/docker-ce.repo >/dev/null 2>&1 - dnf install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin >/dev/null 2>&1 - if ! [ -x "$(command -v docker)" ]; then - echo " - Docker could not be installed automatically. Please visit https://docs.docker.com/engine/install/ and install Docker manually to continue." - exit 1 - fi - systemctl start docker >/dev/null 2>&1 - systemctl enable docker >/dev/null 2>&1 - ;; "alpine" | "postmarketos") apk add docker docker-cli-compose >/dev/null 2>&1 rc-update add docker default >/dev/null 2>&1 @@ -569,8 +556,9 @@ if ! [ -x "$(command -v docker)" ]; then fi ;; "arch") - pacman -Sy docker docker-compose --noconfirm >/dev/null 2>&1 + pacman -Syu --noconfirm --needed docker docker-compose >/dev/null 2>&1 systemctl enable docker.service >/dev/null 2>&1 + systemctl start docker.service >/dev/null 2>&1 if ! [ -x "$(command -v docker)" ]; then echo " - Failed to install Docker with pacman. Try to install it manually." echo " Please visit https://wiki.archlinux.org/title/docker for more information." @@ -581,7 +569,7 @@ if ! [ -x "$(command -v docker)" ]; then dnf install docker -y >/dev/null 2>&1 DOCKER_CONFIG=${DOCKER_CONFIG:-/usr/local/lib/docker} mkdir -p $DOCKER_CONFIG/cli-plugins >/dev/null 2>&1 - curl -sL "https://github.com/docker/compose/releases/latest/download/docker-compose-$(uname -s)-$(uname -m)" -o $DOCKER_CONFIG/cli-plugins/docker-compose >/dev/null 2>&1 + curl -fsSL "https://github.com/docker/compose/releases/latest/download/docker-compose-$(uname -s)-$(uname -m)" -o $DOCKER_CONFIG/cli-plugins/docker-compose >/dev/null 2>&1 chmod +x $DOCKER_CONFIG/cli-plugins/docker-compose >/dev/null 2>&1 systemctl start docker >/dev/null 2>&1 systemctl enable docker >/dev/null 2>&1 @@ -591,34 +579,28 @@ if ! [ -x "$(command -v docker)" ]; then exit 1 fi ;; - "centos" | "fedora" | "rhel" | "tencentos") - if [ -x "$(command -v dnf5)" ]; then - # dnf5 is available - dnf config-manager addrepo --from-repofile=https://download.docker.com/linux/$OS_TYPE/docker-ce.repo --overwrite >/dev/null 2>&1 - else - # dnf5 is not available, use dnf - dnf config-manager --add-repo=https://download.docker.com/linux/$OS_TYPE/docker-ce.repo >/dev/null 2>&1 - fi + "almalinux" | "tencentos") + dnf config-manager --add-repo=https://download.docker.com/linux/centos/docker-ce.repo >/dev/null 2>&1 dnf install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin >/dev/null 2>&1 + systemctl start docker >/dev/null 2>&1 + systemctl enable docker >/dev/null 2>&1 if ! [ -x "$(command -v docker)" ]; then echo " - Docker could not be installed automatically. Please visit https://docs.docker.com/engine/install/ and install Docker manually to continue." exit 1 fi - systemctl start docker >/dev/null 2>&1 - systemctl enable docker >/dev/null 2>&1 ;; - "ubuntu" | "debian" | "raspbian") + "ubuntu" | "debian" | "raspbian" | "centos" | "fedora" | "rhel" | "sles") install_docker if ! [ -x "$(command -v docker)" ]; then - echo " - Automated Docker installation failed. Trying manual installation." - install_docker_manually + echo " - Docker could not be installed automatically. Please visit https://docs.docker.com/engine/install/ and install Docker manually to continue." + exit 1 fi ;; *) install_docker if ! [ -x "$(command -v docker)" ]; then - echo " - Automated Docker installation failed. Trying manual installation." - install_docker_manually + echo " - Docker could not be installed automatically. Please visit https://docs.docker.com/engine/install/ and install Docker manually to continue." + exit 1 fi ;; esac @@ -627,6 +609,19 @@ else echo " - Docker is installed." fi +# Verify minimum Docker version +MIN_DOCKER_VERSION=24 +INSTALLED_DOCKER_VERSION=$(docker version --format '{{.Server.Version}}' 2>/dev/null | cut -d. -f1) +if [ -z "$INSTALLED_DOCKER_VERSION" ]; then + echo " - WARNING: Could not determine Docker version. Please ensure Docker $MIN_DOCKER_VERSION+ is installed." +elif [ "$INSTALLED_DOCKER_VERSION" -lt "$MIN_DOCKER_VERSION" ]; then + echo " - ERROR: Docker version $INSTALLED_DOCKER_VERSION is too old. Coolify requires Docker $MIN_DOCKER_VERSION or newer." + echo " Please upgrade Docker: https://docs.docker.com/engine/install/" + exit 1 +else + echo " - Docker version $(docker version --format '{{.Server.Version}}' 2>/dev/null) meets minimum requirement ($MIN_DOCKER_VERSION+)." +fi + log_section "Step 4/9: Checking Docker configuration" echo "4/9 Checking Docker configuration..." diff --git a/other/nightly/versions.json b/other/nightly/versions.json index 57bb21869..af11ef4d3 100644 --- a/other/nightly/versions.json +++ b/other/nightly/versions.json @@ -1,7 +1,7 @@ { "coolify": { "v4": { - "version": "4.0.0-beta.470" + "version": "4.0.0-beta.471" }, "nightly": { "version": "4.0.0" @@ -13,7 +13,7 @@ "version": "1.0.11" }, "sentinel": { - "version": "0.0.20" + "version": "0.0.21" } }, "traefik": { diff --git a/public/svgs/espocrm.svg b/public/svgs/espocrm.svg new file mode 100644 index 000000000..79d96f8c3 --- /dev/null +++ b/public/svgs/espocrm.svg @@ -0,0 +1,82 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/resources/views/components/applications/links.blade.php b/resources/views/components/applications/links.blade.php index 26b1cedf5..85e8f7431 100644 --- a/resources/views/components/applications/links.blade.php +++ b/resources/views/components/applications/links.blade.php @@ -4,7 +4,7 @@ @if ( (data_get($application, 'fqdn') || - collect(json_decode($this->application->docker_compose_domains))->count() > 0 || + collect(json_decode($this->application->docker_compose_domains))->contains(fn($fqdn) => !empty(data_get($fqdn, 'domain'))) || data_get($application, 'previews', collect([]))->count() > 0 || data_get($application, 'ports_mappings_array')) && data_get($application, 'settings.is_raw_compose_deployment_enabled') !== true) diff --git a/resources/views/livewire/project/application/general.blade.php b/resources/views/livewire/project/application/general.blade.php index 639be8f5d..87465c5d3 100644 --- a/resources/views/livewire/project/application/general.blade.php +++ b/resources/views/livewire/project/application/general.blade.php @@ -50,7 +50,13 @@ !is_null($parsedServices) && count($parsedServices) > 0 && !$application->settings->is_raw_compose_deployment_enabled) -

Domains

+ @php + $hasNonDatabaseService = collect(data_get($parsedServices, 'services', [])) + ->contains(fn($service) => !isDatabaseImage(data_get($service, 'image'))); + @endphp + @if ($hasNonDatabaseService) +

Domains

+ @endif @foreach (data_get($parsedServices, 'services') as $serviceName => $service) @if (!isDatabaseImage(data_get($service, 'image')))
@@ -87,18 +93,20 @@ ]" /> @endcan @endif -
- @if ($application->could_set_build_commands()) - - @endif - @if ($isStatic && $buildPack !== 'static') - - @endif -
+ @if ($application->could_set_build_commands() || ($isStatic && $buildPack !== 'static')) +
+ @if ($application->could_set_build_commands()) + + @endif + @if ($isStatic && $buildPack !== 'static') + + @endif +
+ @endif @if ($buildPack !== 'dockercompose')
@if ($application->settings->is_container_label_readonly_enabled == false) @@ -210,7 +218,7 @@ class="underline" href="https://coolify.io/docs/knowledge-base/docker/registry" @endif
@endif -
+

Build

@if ($application->build_pack === 'dockerimage')

Logs

- @if (str($status)->contains('exited')) -
The resource is not running.
- @else -
- Loading containers... -
-
- @forelse ($servers as $server) -
-

Server: {{ $server->name }}

- @if ($server->isFunctional()) - @if (isset($serverContainers[$server->id]) && count($serverContainers[$server->id]) > 0) - @php - $totalContainers = collect($serverContainers)->flatten(1)->count(); - @endphp - @foreach ($serverContainers[$server->id] as $container) - - @endforeach - @else -
No containers are running on server: {{ $server->name }}
- @endif +
+ Loading containers... +
+
+ @forelse ($servers as $server) +
+

Server: {{ $server->name }}

+ @if ($server->isFunctional()) + @if (isset($serverContainers[$server->id]) && count($serverContainers[$server->id]) > 0) + @php + $totalContainers = collect($serverContainers)->flatten(1)->count(); + @endphp + @foreach ($serverContainers[$server->id] as $container) + + @endforeach @else -
Server {{ $server->name }} is not functional.
+
No containers are running on server: {{ $server->name }}
@endif -
- @empty -
No functional server found for the application.
- @endforelse -
- @endif + @else +
Server {{ $server->name }} is not functional.
+ @endif +
+ @empty +
No functional server found for the application.
+ @endforelse +
@elseif ($type === 'database')

Logs

diff --git a/scripts/install.sh b/scripts/install.sh index b014a3d24..2e1dab326 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -20,7 +20,7 @@ DATE=$(date +"%Y%m%d-%H%M%S") OS_TYPE=$(grep -w "ID" /etc/os-release | cut -d "=" -f 2 | tr -d '"') ENV_FILE="/data/coolify/source/.env" -DOCKER_VERSION="27.0" +DOCKER_VERSION="latest" # TODO: Ask for a user CURRENT_USER=$USER @@ -499,13 +499,10 @@ fi install_docker() { set +e - curl -s https://releases.rancher.com/install-docker/${DOCKER_VERSION}.sh | sh 2>&1 || true + curl -fsSL https://get.docker.com | sh 2>&1 || true if ! [ -x "$(command -v docker)" ]; then - curl -s https://get.docker.com | sh -s -- --version ${DOCKER_VERSION} 2>&1 - if ! [ -x "$(command -v docker)" ]; then - echo "Automated Docker installation failed. Trying manual installation." - install_docker_manually - fi + echo "Automated Docker installation failed. Trying manual installation." + install_docker_manually fi set -e } @@ -548,16 +545,6 @@ if ! [ -x "$(command -v docker)" ]; then echo " - Docker is not installed. Installing Docker. It may take a while." getAJoke case "$OS_TYPE" in - "almalinux") - dnf config-manager --add-repo=https://download.docker.com/linux/centos/docker-ce.repo >/dev/null 2>&1 - dnf install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin >/dev/null 2>&1 - if ! [ -x "$(command -v docker)" ]; then - echo " - Docker could not be installed automatically. Please visit https://docs.docker.com/engine/install/ and install Docker manually to continue." - exit 1 - fi - systemctl start docker >/dev/null 2>&1 - systemctl enable docker >/dev/null 2>&1 - ;; "alpine" | "postmarketos") apk add docker docker-cli-compose >/dev/null 2>&1 rc-update add docker default >/dev/null 2>&1 @@ -569,8 +556,9 @@ if ! [ -x "$(command -v docker)" ]; then fi ;; "arch") - pacman -Sy docker docker-compose --noconfirm >/dev/null 2>&1 + pacman -Syu --noconfirm --needed docker docker-compose >/dev/null 2>&1 systemctl enable docker.service >/dev/null 2>&1 + systemctl start docker.service >/dev/null 2>&1 if ! [ -x "$(command -v docker)" ]; then echo " - Failed to install Docker with pacman. Try to install it manually." echo " Please visit https://wiki.archlinux.org/title/docker for more information." @@ -581,7 +569,7 @@ if ! [ -x "$(command -v docker)" ]; then dnf install docker -y >/dev/null 2>&1 DOCKER_CONFIG=${DOCKER_CONFIG:-/usr/local/lib/docker} mkdir -p $DOCKER_CONFIG/cli-plugins >/dev/null 2>&1 - curl -sL "https://github.com/docker/compose/releases/latest/download/docker-compose-$(uname -s)-$(uname -m)" -o $DOCKER_CONFIG/cli-plugins/docker-compose >/dev/null 2>&1 + curl -fsSL "https://github.com/docker/compose/releases/latest/download/docker-compose-$(uname -s)-$(uname -m)" -o $DOCKER_CONFIG/cli-plugins/docker-compose >/dev/null 2>&1 chmod +x $DOCKER_CONFIG/cli-plugins/docker-compose >/dev/null 2>&1 systemctl start docker >/dev/null 2>&1 systemctl enable docker >/dev/null 2>&1 @@ -591,34 +579,28 @@ if ! [ -x "$(command -v docker)" ]; then exit 1 fi ;; - "centos" | "fedora" | "rhel" | "tencentos") - if [ -x "$(command -v dnf5)" ]; then - # dnf5 is available - dnf config-manager addrepo --from-repofile=https://download.docker.com/linux/$OS_TYPE/docker-ce.repo --overwrite >/dev/null 2>&1 - else - # dnf5 is not available, use dnf - dnf config-manager --add-repo=https://download.docker.com/linux/$OS_TYPE/docker-ce.repo >/dev/null 2>&1 - fi + "almalinux" | "tencentos") + dnf config-manager --add-repo=https://download.docker.com/linux/centos/docker-ce.repo >/dev/null 2>&1 dnf install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin >/dev/null 2>&1 + systemctl start docker >/dev/null 2>&1 + systemctl enable docker >/dev/null 2>&1 if ! [ -x "$(command -v docker)" ]; then echo " - Docker could not be installed automatically. Please visit https://docs.docker.com/engine/install/ and install Docker manually to continue." exit 1 fi - systemctl start docker >/dev/null 2>&1 - systemctl enable docker >/dev/null 2>&1 ;; - "ubuntu" | "debian" | "raspbian") + "ubuntu" | "debian" | "raspbian" | "centos" | "fedora" | "rhel" | "sles") install_docker if ! [ -x "$(command -v docker)" ]; then - echo " - Automated Docker installation failed. Trying manual installation." - install_docker_manually + echo " - Docker could not be installed automatically. Please visit https://docs.docker.com/engine/install/ and install Docker manually to continue." + exit 1 fi ;; *) install_docker if ! [ -x "$(command -v docker)" ]; then - echo " - Automated Docker installation failed. Trying manual installation." - install_docker_manually + echo " - Docker could not be installed automatically. Please visit https://docs.docker.com/engine/install/ and install Docker manually to continue." + exit 1 fi ;; esac @@ -627,6 +609,19 @@ else echo " - Docker is installed." fi +# Verify minimum Docker version +MIN_DOCKER_VERSION=24 +INSTALLED_DOCKER_VERSION=$(docker version --format '{{.Server.Version}}' 2>/dev/null | cut -d. -f1) +if [ -z "$INSTALLED_DOCKER_VERSION" ]; then + echo " - WARNING: Could not determine Docker version. Please ensure Docker $MIN_DOCKER_VERSION+ is installed." +elif [ "$INSTALLED_DOCKER_VERSION" -lt "$MIN_DOCKER_VERSION" ]; then + echo " - ERROR: Docker version $INSTALLED_DOCKER_VERSION is too old. Coolify requires Docker $MIN_DOCKER_VERSION or newer." + echo " Please upgrade Docker: https://docs.docker.com/engine/install/" + exit 1 +else + echo " - Docker version $(docker version --format '{{.Server.Version}}' 2>/dev/null) meets minimum requirement ($MIN_DOCKER_VERSION+)." +fi + log_section "Step 4/9: Checking Docker configuration" echo "4/9 Checking Docker configuration..." diff --git a/templates/compose/booklore.yaml b/templates/compose/booklore.yaml index fddde8de0..a26e52932 100644 --- a/templates/compose/booklore.yaml +++ b/templates/compose/booklore.yaml @@ -1,3 +1,4 @@ +# ignore: true # documentation: https://booklore.org/docs/getting-started # slogan: Booklore is an open-source library management system for your digital book collection. # tags: media, books, kobo, epub, ebook, KOreader diff --git a/templates/compose/espocrm.yaml b/templates/compose/espocrm.yaml new file mode 100644 index 000000000..6fec260c4 --- /dev/null +++ b/templates/compose/espocrm.yaml @@ -0,0 +1,75 @@ +# documentation: https://docs.espocrm.com +# slogan: EspoCRM is a free and open-source CRM platform. +# category: cms +# tags: crm, self-hosted, open-source, workflow, automation, project management +# logo: svgs/espocrm.svg +# port: 80 + +services: + espocrm: + image: espocrm/espocrm:9 + environment: + - SERVICE_URL_ESPOCRM + - ESPOCRM_ADMIN_USERNAME=${ESPOCRM_ADMIN_USERNAME:-admin} + - ESPOCRM_ADMIN_PASSWORD=${SERVICE_PASSWORD_ADMIN} + - ESPOCRM_DATABASE_PLATFORM=Mysql + - ESPOCRM_DATABASE_HOST=espocrm-db + - ESPOCRM_DATABASE_NAME=${MARIADB_DATABASE:-espocrm} + - ESPOCRM_DATABASE_USER=${SERVICE_USER_MARIADB} + - ESPOCRM_DATABASE_PASSWORD=${SERVICE_PASSWORD_MARIADB} + - ESPOCRM_SITE_URL=${SERVICE_URL_ESPOCRM} + volumes: + - espocrm:/var/www/html + healthcheck: + test: ["CMD", "curl", "-f", "http://127.0.0.1:80"] + interval: 2s + start_period: 60s + timeout: 10s + retries: 15 + depends_on: + espocrm-db: + condition: service_healthy + + espocrm-daemon: + image: espocrm/espocrm:9 + container_name: espocrm-daemon + volumes: + - espocrm:/var/www/html + restart: always + entrypoint: docker-daemon.sh + depends_on: + espocrm: + condition: service_healthy + + espocrm-websocket: + image: espocrm/espocrm:9 + container_name: espocrm-websocket + environment: + - SERVICE_URL_ESPOCRM_WEBSOCKET_8080 + - ESPOCRM_CONFIG_USE_WEB_SOCKET=true + - ESPOCRM_CONFIG_WEB_SOCKET_URL=$SERVICE_URL_ESPOCRM_WEBSOCKET + - ESPOCRM_CONFIG_WEB_SOCKET_ZERO_M_Q_SUBSCRIBER_DSN=tcp://*:7777 + - ESPOCRM_CONFIG_WEB_SOCKET_ZERO_M_Q_SUBMISSION_DSN=tcp://espocrm-websocket:7777 + volumes: + - espocrm:/var/www/html + restart: always + entrypoint: docker-websocket.sh + depends_on: + espocrm: + condition: service_healthy + + espocrm-db: + image: mariadb:11.8 + environment: + - MARIADB_DATABASE=${MARIADB_DATABASE:-espocrm} + - MARIADB_USER=${SERVICE_USER_MARIADB} + - MARIADB_PASSWORD=${SERVICE_PASSWORD_MARIADB} + - MARIADB_ROOT_PASSWORD=${SERVICE_PASSWORD_ROOT} + volumes: + - espocrm-db:/var/lib/mysql + healthcheck: + test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"] + interval: 20s + start_period: 10s + timeout: 10s + retries: 3 diff --git a/templates/service-templates-latest.json b/templates/service-templates-latest.json index f22a2ab53..51cb39de0 100644 --- a/templates/service-templates-latest.json +++ b/templates/service-templates-latest.json @@ -310,23 +310,6 @@ "minversion": "0.0.0", "port": "3000" }, - "booklore": { - "documentation": "https://booklore.org/docs/getting-started?utm_source=coolify.io", - "slogan": "Booklore is an open-source library management system for your digital book collection.", - "compose": "c2VydmljZXM6CiAgYm9va2xvcmU6CiAgICBpbWFnZTogJ2Jvb2tsb3JlL2Jvb2tsb3JlOnYxLjE2LjUnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX1VSTF9CT09LTE9SRV84MAogICAgICAtICdVU0VSX0lEPSR7Qk9PS0xPUkVfVVNFUl9JRDotMH0nCiAgICAgIC0gJ0dST1VQX0lEPSR7Qk9PS0xPUkVfR1JPVVBfSUQ6LTB9JwogICAgICAtICdUWj0ke1RaOi1VVEN9JwogICAgICAtICdEQVRBQkFTRV9VUkw9amRiYzptYXJpYWRiOi8vbWFyaWFkYjozMzA2LyR7TUFSSUFEQl9EQVRBQkFTRTotYm9va2xvcmUtZGJ9JwogICAgICAtICdEQVRBQkFTRV9VU0VSTkFNRT0ke1NFUlZJQ0VfVVNFUl9NQVJJQURCfScKICAgICAgLSAnREFUQUJBU0VfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX01BUklBREJ9JwogICAgICAtIEJPT0tMT1JFX1BPUlQ9ODAKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2Jvb2tsb3JlLWRhdGE6L2FwcC9kYXRhJwogICAgICAtICdib29rbG9yZS1ib29rczovYm9va3MnCiAgICAgIC0KICAgICAgICB0eXBlOiBiaW5kCiAgICAgICAgc291cmNlOiB+L2Jvb2tsb3JlCiAgICAgICAgdGFyZ2V0OiAvYm9va2Ryb3AKICAgICAgICBpc19kaXJlY3Rvcnk6IHRydWUKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OiAnd2dldCAtLW5vLXZlcmJvc2UgLS10cmllcz0xIC0tc3BpZGVyIGh0dHA6Ly9sb2NhbGhvc3QvbG9naW4gfHwgZXhpdCAxJwogICAgICBpbnRlcnZhbDogMTBzCiAgICAgIHRpbWVvdXQ6IDVzCiAgICAgIHJldHJpZXM6IDEwCiAgICBkZXBlbmRzX29uOgogICAgICBtYXJpYWRiOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgbWFyaWFkYjoKICAgIGltYWdlOiAnbWFyaWFkYjoxMicKICAgIGVudmlyb25tZW50OgogICAgICAtICdNQVJJQURCX1VTRVI9JHtTRVJWSUNFX1VTRVJfTUFSSUFEQn0nCiAgICAgIC0gJ01BUklBREJfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX01BUklBREJ9JwogICAgICAtICdNQVJJQURCX1JPT1RfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX01BUklBREJST09UfScKICAgICAgLSAnTUFSSUFEQl9EQVRBQkFTRT0ke01BUklBREJfREFUQUJBU0U6LWJvb2tsb3JlLWRifScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ21hcmlhZGItZGF0YTovdmFyL2xpYi9teXNxbCcKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBoZWFsdGhjaGVjay5zaAogICAgICAgIC0gJy0tY29ubmVjdCcKICAgICAgICAtICctLWlubm9kYl9pbml0aWFsaXplZCcKICAgICAgaW50ZXJ2YWw6IDEwcwogICAgICB0aW1lb3V0OiA1cwogICAgICByZXRyaWVzOiAxMAo=", - "tags": [ - "media", - "books", - "kobo", - "epub", - "ebook", - "koreader" - ], - "category": null, - "logo": "svgs/booklore.svg", - "minversion": "0.0.0", - "port": "80" - }, "bookstack": { "documentation": "https://www.bookstackapp.com/docs/?utm_source=coolify.io", "slogan": "BookStack is a simple, self-hosted, easy-to-use platform for organising and storing information", @@ -1204,6 +1187,23 @@ "minversion": "0.0.0", "port": "6052" }, + "espocrm": { + "documentation": "https://docs.espocrm.com?utm_source=coolify.io", + "slogan": "EspoCRM is a free and open-source CRM platform.", + "compose": "c2VydmljZXM6CiAgZXNwb2NybToKICAgIGltYWdlOiAnZXNwb2NybS9lc3BvY3JtOjknCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX1VSTF9FU1BPQ1JNCiAgICAgIC0gJ0VTUE9DUk1fQURNSU5fVVNFUk5BTUU9JHtFU1BPQ1JNX0FETUlOX1VTRVJOQU1FOi1hZG1pbn0nCiAgICAgIC0gJ0VTUE9DUk1fQURNSU5fUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX0FETUlOfScKICAgICAgLSBFU1BPQ1JNX0RBVEFCQVNFX1BMQVRGT1JNPU15c3FsCiAgICAgIC0gRVNQT0NSTV9EQVRBQkFTRV9IT1NUPWVzcG9jcm0tZGIKICAgICAgLSAnRVNQT0NSTV9EQVRBQkFTRV9OQU1FPSR7TUFSSUFEQl9EQVRBQkFTRTotZXNwb2NybX0nCiAgICAgIC0gJ0VTUE9DUk1fREFUQUJBU0VfVVNFUj0ke1NFUlZJQ0VfVVNFUl9NQVJJQURCfScKICAgICAgLSAnRVNQT0NSTV9EQVRBQkFTRV9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfTUFSSUFEQn0nCiAgICAgIC0gJ0VTUE9DUk1fU0lURV9VUkw9JHtTRVJWSUNFX1VSTF9FU1BPQ1JNfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2VzcG9jcm06L3Zhci93d3cvaHRtbCcKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBjdXJsCiAgICAgICAgLSAnLWYnCiAgICAgICAgLSAnaHR0cDovLzEyNy4wLjAuMTo4MCcKICAgICAgaW50ZXJ2YWw6IDJzCiAgICAgIHN0YXJ0X3BlcmlvZDogNjBzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiAxNQogICAgZGVwZW5kc19vbjoKICAgICAgZXNwb2NybS1kYjoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogIGVzcG9jcm0tZGFlbW9uOgogICAgaW1hZ2U6ICdlc3BvY3JtL2VzcG9jcm06OScKICAgIGNvbnRhaW5lcl9uYW1lOiBlc3BvY3JtLWRhZW1vbgogICAgdm9sdW1lczoKICAgICAgLSAnZXNwb2NybTovdmFyL3d3dy9odG1sJwogICAgcmVzdGFydDogYWx3YXlzCiAgICBlbnRyeXBvaW50OiBkb2NrZXItZGFlbW9uLnNoCiAgICBkZXBlbmRzX29uOgogICAgICBlc3BvY3JtOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgZXNwb2NybS13ZWJzb2NrZXQ6CiAgICBpbWFnZTogJ2VzcG9jcm0vZXNwb2NybTo5JwogICAgY29udGFpbmVyX25hbWU6IGVzcG9jcm0td2Vic29ja2V0CiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX1VSTF9FU1BPQ1JNX1dFQlNPQ0tFVF84MDgwCiAgICAgIC0gRVNQT0NSTV9DT05GSUdfVVNFX1dFQl9TT0NLRVQ9dHJ1ZQogICAgICAtIEVTUE9DUk1fQ09ORklHX1dFQl9TT0NLRVRfVVJMPSRTRVJWSUNFX1VSTF9FU1BPQ1JNX1dFQlNPQ0tFVAogICAgICAtICdFU1BPQ1JNX0NPTkZJR19XRUJfU09DS0VUX1pFUk9fTV9RX1NVQlNDUklCRVJfRFNOPXRjcDovLyo6Nzc3NycKICAgICAgLSAnRVNQT0NSTV9DT05GSUdfV0VCX1NPQ0tFVF9aRVJPX01fUV9TVUJNSVNTSU9OX0RTTj10Y3A6Ly9lc3BvY3JtLXdlYnNvY2tldDo3Nzc3JwogICAgdm9sdW1lczoKICAgICAgLSAnZXNwb2NybTovdmFyL3d3dy9odG1sJwogICAgcmVzdGFydDogYWx3YXlzCiAgICBlbnRyeXBvaW50OiBkb2NrZXItd2Vic29ja2V0LnNoCiAgICBkZXBlbmRzX29uOgogICAgICBlc3BvY3JtOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgZXNwb2NybS1kYjoKICAgIGltYWdlOiAnbWFyaWFkYjoxMS44JwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ01BUklBREJfREFUQUJBU0U9JHtNQVJJQURCX0RBVEFCQVNFOi1lc3BvY3JtfScKICAgICAgLSAnTUFSSUFEQl9VU0VSPSR7U0VSVklDRV9VU0VSX01BUklBREJ9JwogICAgICAtICdNQVJJQURCX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9NQVJJQURCfScKICAgICAgLSAnTUFSSUFEQl9ST09UX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9ST09UfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2VzcG9jcm0tZGI6L3Zhci9saWIvbXlzcWwnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gaGVhbHRoY2hlY2suc2gKICAgICAgICAtICctLWNvbm5lY3QnCiAgICAgICAgLSAnLS1pbm5vZGJfaW5pdGlhbGl6ZWQnCiAgICAgIGludGVydmFsOiAyMHMKICAgICAgc3RhcnRfcGVyaW9kOiAxMHMKICAgICAgdGltZW91dDogMTBzCiAgICAgIHJldHJpZXM6IDMK", + "tags": [ + "crm", + "self-hosted", + "open-source", + "workflow", + "automation", + "project management" + ], + "category": "cms", + "logo": "svgs/espocrm.svg", + "minversion": "0.0.0", + "port": "80" + }, "evolution-api": { "documentation": "https://doc.evolution-api.com/v2/en/get-started/introduction?utm_source=coolify.io", "slogan": "Multi-platform messaging (whatsapp and more) integration API", diff --git a/templates/service-templates.json b/templates/service-templates.json index 22d0d6d8c..85445faf6 100644 --- a/templates/service-templates.json +++ b/templates/service-templates.json @@ -310,23 +310,6 @@ "minversion": "0.0.0", "port": "3000" }, - "booklore": { - "documentation": "https://booklore.org/docs/getting-started?utm_source=coolify.io", - "slogan": "Booklore is an open-source library management system for your digital book collection.", - "compose": "c2VydmljZXM6CiAgYm9va2xvcmU6CiAgICBpbWFnZTogJ2Jvb2tsb3JlL2Jvb2tsb3JlOnYxLjE2LjUnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX0ZRRE5fQk9PS0xPUkVfODAKICAgICAgLSAnVVNFUl9JRD0ke0JPT0tMT1JFX1VTRVJfSUQ6LTB9JwogICAgICAtICdHUk9VUF9JRD0ke0JPT0tMT1JFX0dST1VQX0lEOi0wfScKICAgICAgLSAnVFo9JHtUWjotVVRDfScKICAgICAgLSAnREFUQUJBU0VfVVJMPWpkYmM6bWFyaWFkYjovL21hcmlhZGI6MzMwNi8ke01BUklBREJfREFUQUJBU0U6LWJvb2tsb3JlLWRifScKICAgICAgLSAnREFUQUJBU0VfVVNFUk5BTUU9JHtTRVJWSUNFX1VTRVJfTUFSSUFEQn0nCiAgICAgIC0gJ0RBVEFCQVNFX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9NQVJJQURCfScKICAgICAgLSBCT09LTE9SRV9QT1JUPTgwCiAgICB2b2x1bWVzOgogICAgICAtICdib29rbG9yZS1kYXRhOi9hcHAvZGF0YScKICAgICAgLSAnYm9va2xvcmUtYm9va3M6L2Jvb2tzJwogICAgICAtCiAgICAgICAgdHlwZTogYmluZAogICAgICAgIHNvdXJjZTogfi9ib29rbG9yZQogICAgICAgIHRhcmdldDogL2Jvb2tkcm9wCiAgICAgICAgaXNfZGlyZWN0b3J5OiB0cnVlCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDogJ3dnZXQgLS1uby12ZXJib3NlIC0tdHJpZXM9MSAtLXNwaWRlciBodHRwOi8vbG9jYWxob3N0L2xvZ2luIHx8IGV4aXQgMScKICAgICAgaW50ZXJ2YWw6IDEwcwogICAgICB0aW1lb3V0OiA1cwogICAgICByZXRyaWVzOiAxMAogICAgZGVwZW5kc19vbjoKICAgICAgbWFyaWFkYjoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogIG1hcmlhZGI6CiAgICBpbWFnZTogJ21hcmlhZGI6MTInCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnTUFSSUFEQl9VU0VSPSR7U0VSVklDRV9VU0VSX01BUklBREJ9JwogICAgICAtICdNQVJJQURCX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9NQVJJQURCfScKICAgICAgLSAnTUFSSUFEQl9ST09UX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9NQVJJQURCUk9PVH0nCiAgICAgIC0gJ01BUklBREJfREFUQUJBU0U9JHtNQVJJQURCX0RBVEFCQVNFOi1ib29rbG9yZS1kYn0nCiAgICB2b2x1bWVzOgogICAgICAtICdtYXJpYWRiLWRhdGE6L3Zhci9saWIvbXlzcWwnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gaGVhbHRoY2hlY2suc2gKICAgICAgICAtICctLWNvbm5lY3QnCiAgICAgICAgLSAnLS1pbm5vZGJfaW5pdGlhbGl6ZWQnCiAgICAgIGludGVydmFsOiAxMHMKICAgICAgdGltZW91dDogNXMKICAgICAgcmV0cmllczogMTAK", - "tags": [ - "media", - "books", - "kobo", - "epub", - "ebook", - "koreader" - ], - "category": null, - "logo": "svgs/booklore.svg", - "minversion": "0.0.0", - "port": "80" - }, "bookstack": { "documentation": "https://www.bookstackapp.com/docs/?utm_source=coolify.io", "slogan": "BookStack is a simple, self-hosted, easy-to-use platform for organising and storing information", @@ -1204,6 +1187,23 @@ "minversion": "0.0.0", "port": "6052" }, + "espocrm": { + "documentation": "https://docs.espocrm.com?utm_source=coolify.io", + "slogan": "EspoCRM is a free and open-source CRM platform.", + "compose": "c2VydmljZXM6CiAgZXNwb2NybToKICAgIGltYWdlOiAnZXNwb2NybS9lc3BvY3JtOjknCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX0ZRRE5fRVNQT0NSTQogICAgICAtICdFU1BPQ1JNX0FETUlOX1VTRVJOQU1FPSR7RVNQT0NSTV9BRE1JTl9VU0VSTkFNRTotYWRtaW59JwogICAgICAtICdFU1BPQ1JNX0FETUlOX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9BRE1JTn0nCiAgICAgIC0gRVNQT0NSTV9EQVRBQkFTRV9QTEFURk9STT1NeXNxbAogICAgICAtIEVTUE9DUk1fREFUQUJBU0VfSE9TVD1lc3BvY3JtLWRiCiAgICAgIC0gJ0VTUE9DUk1fREFUQUJBU0VfTkFNRT0ke01BUklBREJfREFUQUJBU0U6LWVzcG9jcm19JwogICAgICAtICdFU1BPQ1JNX0RBVEFCQVNFX1VTRVI9JHtTRVJWSUNFX1VTRVJfTUFSSUFEQn0nCiAgICAgIC0gJ0VTUE9DUk1fREFUQUJBU0VfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX01BUklBREJ9JwogICAgICAtICdFU1BPQ1JNX1NJVEVfVVJMPSR7U0VSVklDRV9GUUROX0VTUE9DUk19JwogICAgdm9sdW1lczoKICAgICAgLSAnZXNwb2NybTovdmFyL3d3dy9odG1sJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIGN1cmwKICAgICAgICAtICctZicKICAgICAgICAtICdodHRwOi8vMTI3LjAuMC4xOjgwJwogICAgICBpbnRlcnZhbDogMnMKICAgICAgc3RhcnRfcGVyaW9kOiA2MHMKICAgICAgdGltZW91dDogMTBzCiAgICAgIHJldHJpZXM6IDE1CiAgICBkZXBlbmRzX29uOgogICAgICBlc3BvY3JtLWRiOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgZXNwb2NybS1kYWVtb246CiAgICBpbWFnZTogJ2VzcG9jcm0vZXNwb2NybTo5JwogICAgY29udGFpbmVyX25hbWU6IGVzcG9jcm0tZGFlbW9uCiAgICB2b2x1bWVzOgogICAgICAtICdlc3BvY3JtOi92YXIvd3d3L2h0bWwnCiAgICByZXN0YXJ0OiBhbHdheXMKICAgIGVudHJ5cG9pbnQ6IGRvY2tlci1kYWVtb24uc2gKICAgIGRlcGVuZHNfb246CiAgICAgIGVzcG9jcm06CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICBlc3BvY3JtLXdlYnNvY2tldDoKICAgIGltYWdlOiAnZXNwb2NybS9lc3BvY3JtOjknCiAgICBjb250YWluZXJfbmFtZTogZXNwb2NybS13ZWJzb2NrZXQKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfRlFETl9FU1BPQ1JNX1dFQlNPQ0tFVF84MDgwCiAgICAgIC0gRVNQT0NSTV9DT05GSUdfVVNFX1dFQl9TT0NLRVQ9dHJ1ZQogICAgICAtIEVTUE9DUk1fQ09ORklHX1dFQl9TT0NLRVRfVVJMPSRTRVJWSUNFX0ZRRE5fRVNQT0NSTV9XRUJTT0NLRVQKICAgICAgLSAnRVNQT0NSTV9DT05GSUdfV0VCX1NPQ0tFVF9aRVJPX01fUV9TVUJTQ1JJQkVSX0RTTj10Y3A6Ly8qOjc3NzcnCiAgICAgIC0gJ0VTUE9DUk1fQ09ORklHX1dFQl9TT0NLRVRfWkVST19NX1FfU1VCTUlTU0lPTl9EU049dGNwOi8vZXNwb2NybS13ZWJzb2NrZXQ6Nzc3NycKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2VzcG9jcm06L3Zhci93d3cvaHRtbCcKICAgIHJlc3RhcnQ6IGFsd2F5cwogICAgZW50cnlwb2ludDogZG9ja2VyLXdlYnNvY2tldC5zaAogICAgZGVwZW5kc19vbjoKICAgICAgZXNwb2NybToKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogIGVzcG9jcm0tZGI6CiAgICBpbWFnZTogJ21hcmlhZGI6MTEuOCcKICAgIGVudmlyb25tZW50OgogICAgICAtICdNQVJJQURCX0RBVEFCQVNFPSR7TUFSSUFEQl9EQVRBQkFTRTotZXNwb2NybX0nCiAgICAgIC0gJ01BUklBREJfVVNFUj0ke1NFUlZJQ0VfVVNFUl9NQVJJQURCfScKICAgICAgLSAnTUFSSUFEQl9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfTUFSSUFEQn0nCiAgICAgIC0gJ01BUklBREJfUk9PVF9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUk9PVH0nCiAgICB2b2x1bWVzOgogICAgICAtICdlc3BvY3JtLWRiOi92YXIvbGliL215c3FsJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIGhlYWx0aGNoZWNrLnNoCiAgICAgICAgLSAnLS1jb25uZWN0JwogICAgICAgIC0gJy0taW5ub2RiX2luaXRpYWxpemVkJwogICAgICBpbnRlcnZhbDogMjBzCiAgICAgIHN0YXJ0X3BlcmlvZDogMTBzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiAzCg==", + "tags": [ + "crm", + "self-hosted", + "open-source", + "workflow", + "automation", + "project management" + ], + "category": "cms", + "logo": "svgs/espocrm.svg", + "minversion": "0.0.0", + "port": "80" + }, "evolution-api": { "documentation": "https://doc.evolution-api.com/v2/en/get-started/introduction?utm_source=coolify.io", "slogan": "Multi-platform messaging (whatsapp and more) integration API", diff --git a/tests/Feature/ActivityMonitorCrossTeamTest.php b/tests/Feature/ActivityMonitorCrossTeamTest.php new file mode 100644 index 000000000..7e4aebc2f --- /dev/null +++ b/tests/Feature/ActivityMonitorCrossTeamTest.php @@ -0,0 +1,67 @@ +team = Team::factory()->create(); + $this->user = User::factory()->create(); + $this->team->members()->attach($this->user->id, ['role' => 'owner']); + + $this->otherTeam = Team::factory()->create(); +}); + +test('hydrateActivity blocks access to another teams activity', function () { + $otherActivity = Activity::create([ + 'log_name' => 'default', + 'description' => 'test activity', + 'properties' => ['team_id' => $this->otherTeam->id], + ]); + + $this->actingAs($this->user); + session(['currentTeam' => ['id' => $this->team->id]]); + + $component = Livewire::test(ActivityMonitor::class) + ->set('activityId', $otherActivity->id) + ->assertSet('activity', null); +}); + +test('hydrateActivity allows access to own teams activity', function () { + $ownActivity = Activity::create([ + 'log_name' => 'default', + 'description' => 'test activity', + 'properties' => ['team_id' => $this->team->id], + ]); + + $this->actingAs($this->user); + session(['currentTeam' => ['id' => $this->team->id]]); + + $component = Livewire::test(ActivityMonitor::class) + ->set('activityId', $ownActivity->id); + + expect($component->get('activity'))->not->toBeNull(); + expect($component->get('activity')->id)->toBe($ownActivity->id); +}); + +test('hydrateActivity allows access to activity without team_id in properties', function () { + $legacyActivity = Activity::create([ + 'log_name' => 'default', + 'description' => 'legacy activity', + 'properties' => [], + ]); + + $this->actingAs($this->user); + session(['currentTeam' => ['id' => $this->team->id]]); + + $component = Livewire::test(ActivityMonitor::class) + ->set('activityId', $legacyActivity->id); + + expect($component->get('activity'))->not->toBeNull(); + expect($component->get('activity')->id)->toBe($legacyActivity->id); +}); diff --git a/tests/Feature/DatabaseBackupJobTest.php b/tests/Feature/DatabaseBackupJobTest.php index 37c377dab..05cb21f12 100644 --- a/tests/Feature/DatabaseBackupJobTest.php +++ b/tests/Feature/DatabaseBackupJobTest.php @@ -120,6 +120,82 @@ expect($unrelatedBackup->save_s3)->toBeTruthy(); }); +test('failed method does not overwrite successful backup status', function () { + $team = Team::factory()->create(); + + $backup = ScheduledDatabaseBackup::create([ + 'frequency' => '0 0 * * *', + 'save_s3' => false, + 'database_type' => 'App\Models\StandalonePostgresql', + 'database_id' => 1, + 'team_id' => $team->id, + ]); + + $log = ScheduledDatabaseBackupExecution::create([ + 'uuid' => 'test-uuid-success-guard', + 'database_name' => 'test_db', + 'filename' => '/backup/test.dmp', + 'scheduled_database_backup_id' => $backup->id, + 'status' => 'success', + 'message' => 'Backup completed successfully', + 'size' => 1024, + ]); + + $job = new DatabaseBackupJob($backup); + + $reflection = new ReflectionClass($job); + + $teamProp = $reflection->getProperty('team'); + $teamProp->setValue($job, $team); + + $logUuidProp = $reflection->getProperty('backup_log_uuid'); + $logUuidProp->setValue($job, 'test-uuid-success-guard'); + + // Simulate a post-backup failure (e.g. notification error) + $job->failed(new Exception('Request to the Resend API failed')); + + $log->refresh(); + expect($log->status)->toBe('success'); + expect($log->message)->toBe('Backup completed successfully'); + expect($log->size)->toBe(1024); +}); + +test('failed method updates status when backup was not successful', function () { + $team = Team::factory()->create(); + + $backup = ScheduledDatabaseBackup::create([ + 'frequency' => '0 0 * * *', + 'save_s3' => false, + 'database_type' => 'App\Models\StandalonePostgresql', + 'database_id' => 1, + 'team_id' => $team->id, + ]); + + $log = ScheduledDatabaseBackupExecution::create([ + 'uuid' => 'test-uuid-pending-guard', + 'database_name' => 'test_db', + 'filename' => '/backup/test.dmp', + 'scheduled_database_backup_id' => $backup->id, + 'status' => 'pending', + ]); + + $job = new DatabaseBackupJob($backup); + + $reflection = new ReflectionClass($job); + + $teamProp = $reflection->getProperty('team'); + $teamProp->setValue($job, $team); + + $logUuidProp = $reflection->getProperty('backup_log_uuid'); + $logUuidProp->setValue($job, 'test-uuid-pending-guard'); + + $job->failed(new Exception('Some real failure')); + + $log->refresh(); + expect($log->status)->toBe('failed'); + expect($log->message)->toContain('Some real failure'); +}); + test('s3 storage has scheduled backups relationship', function () { $team = Team::factory()->create(); diff --git a/tests/Feature/DomainsByServerApiTest.php b/tests/Feature/DomainsByServerApiTest.php index 1e799bec5..ea799275b 100644 --- a/tests/Feature/DomainsByServerApiTest.php +++ b/tests/Feature/DomainsByServerApiTest.php @@ -16,11 +16,12 @@ $this->user = User::factory()->create(); $this->team->members()->attach($this->user->id, ['role' => 'owner']); - $this->token = $this->user->createToken('test-token', ['*'], $this->team->id); + session(['currentTeam' => $this->team]); + $this->token = $this->user->createToken('test-token', ['*']); $this->bearerToken = $this->token->plainTextToken; $this->server = Server::factory()->create(['team_id' => $this->team->id]); - $this->destination = StandaloneDocker::factory()->create(['server_id' => $this->server->id]); + $this->destination = StandaloneDocker::where('server_id', $this->server->id)->first(); $this->project = Project::factory()->create(['team_id' => $this->team->id]); $this->environment = Environment::factory()->create(['project_id' => $this->project->id]); }); @@ -53,7 +54,7 @@ function authHeaders(): array $otherTeam->members()->attach($otherUser->id, ['role' => 'owner']); $otherServer = Server::factory()->create(['team_id' => $otherTeam->id]); - $otherDestination = StandaloneDocker::factory()->create(['server_id' => $otherServer->id]); + $otherDestination = StandaloneDocker::where('server_id', $otherServer->id)->first(); $otherProject = Project::factory()->create(['team_id' => $otherTeam->id]); $otherEnvironment = Environment::factory()->create(['project_id' => $otherProject->id]); @@ -78,3 +79,45 @@ function authHeaders(): array $response->assertNotFound(); $response->assertJson(['message' => 'Application not found.']); }); + +test('returns 404 when server uuid belongs to another team', function () { + $otherTeam = Team::factory()->create(); + $otherUser = User::factory()->create(); + $otherTeam->members()->attach($otherUser->id, ['role' => 'owner']); + + $otherServer = Server::factory()->create(['team_id' => $otherTeam->id]); + + $response = $this->withHeaders(authHeaders()) + ->getJson("/api/v1/servers/{$otherServer->uuid}/domains"); + + $response->assertNotFound(); + $response->assertJson(['message' => 'Server not found.']); +}); + +test('only returns domains for applications on the specified server', function () { + $application = Application::factory()->create([ + 'fqdn' => 'https://app-on-server.example.com', + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + ]); + + $otherServer = Server::factory()->create(['team_id' => $this->team->id]); + $otherDestination = StandaloneDocker::where('server_id', $otherServer->id)->first(); + + $applicationOnOtherServer = Application::factory()->create([ + 'fqdn' => 'https://app-on-other-server.example.com', + 'environment_id' => $this->environment->id, + 'destination_id' => $otherDestination->id, + 'destination_type' => $otherDestination->getMorphClass(), + ]); + + $response = $this->withHeaders(authHeaders()) + ->getJson("/api/v1/servers/{$this->server->uuid}/domains"); + + $response->assertOk(); + $responseContent = $response->json(); + $allDomains = collect($responseContent)->pluck('domains')->flatten()->toArray(); + expect($allDomains)->toContain('app-on-server.example.com'); + expect($allDomains)->not->toContain('app-on-other-server.example.com'); +}); diff --git a/tests/Feature/PreviewEnvVarFallbackTest.php b/tests/Feature/PreviewEnvVarFallbackTest.php new file mode 100644 index 000000000..e3fc3023f --- /dev/null +++ b/tests/Feature/PreviewEnvVarFallbackTest.php @@ -0,0 +1,247 @@ +user = User::factory()->create(); + $this->team = Team::factory()->create(); + $this->user->teams()->attach($this->team); + + $this->project = Project::factory()->create(['team_id' => $this->team->id]); + $this->environment = Environment::factory()->create([ + 'project_id' => $this->project->id, + ]); + + $this->application = Application::factory()->create([ + 'environment_id' => $this->environment->id, + ]); + + $this->actingAs($this->user); +}); + +/** + * Simulate the preview .env generation logic from + * ApplicationDeploymentJob::generate_runtime_environment_variables() + * including the production fallback fix. + */ +function simulatePreviewEnvGeneration(Application $application): \Illuminate\Support\Collection +{ + $sorted_environment_variables = $application->environment_variables->sortBy('id'); + $sorted_environment_variables_preview = $application->environment_variables_preview->sortBy('id'); + + $envs = collect([]); + + // Preview vars + $runtime_environment_variables_preview = $sorted_environment_variables_preview->filter(fn ($env) => $env->is_runtime); + foreach ($runtime_environment_variables_preview as $env) { + $envs->push($env->key.'='.$env->real_value); + } + + // Fallback: production vars not overridden by preview, + // only when preview vars are configured + if ($runtime_environment_variables_preview->isNotEmpty()) { + $previewKeys = $runtime_environment_variables_preview->pluck('key')->toArray(); + $fallback_production_vars = $sorted_environment_variables->filter(function ($env) use ($previewKeys) { + return $env->is_runtime && ! in_array($env->key, $previewKeys); + }); + foreach ($fallback_production_vars as $env) { + $envs->push($env->key.'='.$env->real_value); + } + } + + return $envs; +} + +test('production vars fall back when preview vars exist but do not cover all keys', function () { + // Create two production vars (booted hook auto-creates preview copies) + EnvironmentVariable::create([ + 'key' => 'DB_PASSWORD', + 'value' => 'secret123', + 'is_preview' => false, + 'is_runtime' => true, + 'is_buildtime' => false, + 'resourceable_type' => Application::class, + 'resourceable_id' => $this->application->id, + ]); + + EnvironmentVariable::create([ + 'key' => 'APP_KEY', + 'value' => 'app_key_value', + 'is_preview' => false, + 'is_runtime' => true, + 'is_buildtime' => false, + 'resourceable_type' => Application::class, + 'resourceable_id' => $this->application->id, + ]); + + // Delete only the DB_PASSWORD preview copy — APP_KEY preview copy remains + $this->application->environment_variables_preview()->where('key', 'DB_PASSWORD')->delete(); + $this->application->refresh(); + + // Preview has APP_KEY but not DB_PASSWORD + expect($this->application->environment_variables_preview()->where('key', 'APP_KEY')->count())->toBe(1); + expect($this->application->environment_variables_preview()->where('key', 'DB_PASSWORD')->count())->toBe(0); + + $envs = simulatePreviewEnvGeneration($this->application); + + $envString = $envs->implode("\n"); + // DB_PASSWORD should fall back from production + expect($envString)->toContain('DB_PASSWORD='); + // APP_KEY should use the preview value + expect($envString)->toContain('APP_KEY='); +}); + +test('no fallback when no preview vars are configured at all', function () { + // Create a production-only var (booted hook auto-creates preview copy) + EnvironmentVariable::create([ + 'key' => 'DB_PASSWORD', + 'value' => 'secret123', + 'is_preview' => false, + 'is_runtime' => true, + 'is_buildtime' => false, + 'resourceable_type' => Application::class, + 'resourceable_id' => $this->application->id, + ]); + + // Delete ALL preview copies — simulates no preview config + $this->application->environment_variables_preview()->delete(); + $this->application->refresh(); + + expect($this->application->environment_variables_preview()->count())->toBe(0); + + $envs = simulatePreviewEnvGeneration($this->application); + + $envString = $envs->implode("\n"); + // Should NOT fall back to production when no preview vars exist + expect($envString)->not->toContain('DB_PASSWORD='); +}); + +test('preview var overrides production var when both exist', function () { + // Create production var (auto-creates preview copy) + EnvironmentVariable::create([ + 'key' => 'DB_PASSWORD', + 'value' => 'prod_password', + 'is_preview' => false, + 'is_runtime' => true, + 'is_buildtime' => false, + 'resourceable_type' => Application::class, + 'resourceable_id' => $this->application->id, + ]); + + // Update the auto-created preview copy with a different value + $this->application->environment_variables_preview() + ->where('key', 'DB_PASSWORD') + ->update(['value' => encrypt('preview_password')]); + + $this->application->refresh(); + $envs = simulatePreviewEnvGeneration($this->application); + + // Should contain preview value only, not production + $envEntries = $envs->filter(fn ($e) => str_starts_with($e, 'DB_PASSWORD=')); + expect($envEntries)->toHaveCount(1); + expect($envEntries->first())->toContain('preview_password'); +}); + +test('preview-only var works without production counterpart', function () { + // Create a preview-only var directly (no production counterpart) + EnvironmentVariable::create([ + 'key' => 'PREVIEW_ONLY_VAR', + 'value' => 'preview_value', + 'is_preview' => true, + 'is_runtime' => true, + 'is_buildtime' => false, + 'resourceable_type' => Application::class, + 'resourceable_id' => $this->application->id, + ]); + + $this->application->refresh(); + $envs = simulatePreviewEnvGeneration($this->application); + + $envString = $envs->implode("\n"); + expect($envString)->toContain('PREVIEW_ONLY_VAR='); +}); + +test('buildtime-only production vars are not included in preview fallback', function () { + // Create a runtime preview var so fallback is active + EnvironmentVariable::create([ + 'key' => 'SOME_PREVIEW_VAR', + 'value' => 'preview_value', + 'is_preview' => true, + 'is_runtime' => true, + 'is_buildtime' => false, + 'resourceable_type' => Application::class, + 'resourceable_id' => $this->application->id, + ]); + + // Create a buildtime-only production var + EnvironmentVariable::create([ + 'key' => 'BUILD_SECRET', + 'value' => 'build_only', + 'is_preview' => false, + 'is_runtime' => false, + 'is_buildtime' => true, + 'resourceable_type' => Application::class, + 'resourceable_id' => $this->application->id, + ]); + + // Delete the auto-created preview copy of BUILD_SECRET + $this->application->environment_variables_preview()->where('key', 'BUILD_SECRET')->delete(); + $this->application->refresh(); + + $envs = simulatePreviewEnvGeneration($this->application); + + $envString = $envs->implode("\n"); + expect($envString)->not->toContain('BUILD_SECRET'); + expect($envString)->toContain('SOME_PREVIEW_VAR='); +}); + +test('preview env var inherits is_runtime and is_buildtime from production var', function () { + // Create production var WITH explicit flags + EnvironmentVariable::create([ + 'key' => 'DB_PASSWORD', + 'value' => 'secret123', + 'is_preview' => false, + 'is_runtime' => true, + 'is_buildtime' => true, + 'resourceable_type' => Application::class, + 'resourceable_id' => $this->application->id, + ]); + + $preview = EnvironmentVariable::where('key', 'DB_PASSWORD') + ->where('is_preview', true) + ->where('resourceable_id', $this->application->id) + ->first(); + + expect($preview)->not->toBeNull(); + expect($preview->is_runtime)->toBeTrue(); + expect($preview->is_buildtime)->toBeTrue(); +}); + +test('preview env var gets correct defaults when production var created without explicit flags', function () { + // Simulate code paths (docker-compose parser, dev view bulk submit) that create + // env vars without explicitly setting is_runtime/is_buildtime + EnvironmentVariable::create([ + 'key' => 'DB_PASSWORD', + 'value' => 'secret123', + 'is_preview' => false, + 'resourceable_type' => Application::class, + 'resourceable_id' => $this->application->id, + ]); + + $preview = EnvironmentVariable::where('key', 'DB_PASSWORD') + ->where('is_preview', true) + ->where('resourceable_id', $this->application->id) + ->first(); + + expect($preview)->not->toBeNull(); + expect($preview->is_runtime)->toBeTrue(); + expect($preview->is_buildtime)->toBeTrue(); +}); diff --git a/tests/Feature/Subscription/StripeProcessJobTest.php b/tests/Feature/Subscription/StripeProcessJobTest.php index 95cff188a..0a93f858c 100644 --- a/tests/Feature/Subscription/StripeProcessJobTest.php +++ b/tests/Feature/Subscription/StripeProcessJobTest.php @@ -50,6 +50,93 @@ // Critical: stripe_invoice_paid must remain false — payment not yet confirmed expect($subscription->stripe_invoice_paid)->toBeFalsy(); }); + + test('created event updates existing subscription instead of duplicating', function () { + Queue::fake(); + + Subscription::create([ + 'team_id' => $this->team->id, + 'stripe_subscription_id' => 'sub_old', + 'stripe_customer_id' => 'cus_old', + 'stripe_invoice_paid' => true, + ]); + + $event = [ + 'type' => 'customer.subscription.created', + 'data' => [ + 'object' => [ + 'customer' => 'cus_new_123', + 'id' => 'sub_new_123', + 'metadata' => [ + 'team_id' => $this->team->id, + 'user_id' => $this->user->id, + ], + ], + ], + ]; + + $job = new StripeProcessJob($event); + $job->handle(); + + expect(Subscription::where('team_id', $this->team->id)->count())->toBe(1); + $subscription = Subscription::where('team_id', $this->team->id)->first(); + expect($subscription->stripe_subscription_id)->toBe('sub_new_123'); + expect($subscription->stripe_customer_id)->toBe('cus_new_123'); + }); +}); + +describe('checkout.session.completed', function () { + test('creates subscription for new team', function () { + Queue::fake(); + + $event = [ + 'type' => 'checkout.session.completed', + 'data' => [ + 'object' => [ + 'client_reference_id' => $this->user->id.':'.$this->team->id, + 'subscription' => 'sub_checkout_123', + 'customer' => 'cus_checkout_123', + ], + ], + ]; + + $job = new StripeProcessJob($event); + $job->handle(); + + $subscription = Subscription::where('team_id', $this->team->id)->first(); + expect($subscription)->not->toBeNull(); + expect($subscription->stripe_invoice_paid)->toBeTruthy(); + }); + + test('updates existing subscription instead of duplicating', function () { + Queue::fake(); + + Subscription::create([ + 'team_id' => $this->team->id, + 'stripe_subscription_id' => 'sub_old', + 'stripe_customer_id' => 'cus_old', + 'stripe_invoice_paid' => false, + ]); + + $event = [ + 'type' => 'checkout.session.completed', + 'data' => [ + 'object' => [ + 'client_reference_id' => $this->user->id.':'.$this->team->id, + 'subscription' => 'sub_checkout_new', + 'customer' => 'cus_checkout_new', + ], + ], + ]; + + $job = new StripeProcessJob($event); + $job->handle(); + + expect(Subscription::where('team_id', $this->team->id)->count())->toBe(1); + $subscription = Subscription::where('team_id', $this->team->id)->first(); + expect($subscription->stripe_subscription_id)->toBe('sub_checkout_new'); + expect($subscription->stripe_invoice_paid)->toBeTruthy(); + }); }); describe('customer.subscription.updated clamps quantity to MAX_SERVER_LIMIT', function () { diff --git a/tests/Feature/TeamServerLimitTest.php b/tests/Feature/TeamServerLimitTest.php new file mode 100644 index 000000000..11d7f09d1 --- /dev/null +++ b/tests/Feature/TeamServerLimitTest.php @@ -0,0 +1,53 @@ +set('constants.coolify.self_hosted', true); +}); + +it('returns server limit when team is passed directly without session', function () { + $team = Team::factory()->create(); + + $limit = Team::serverLimit($team); + + // self_hosted returns 999999999999 + expect($limit)->toBe(999999999999); +}); + +it('returns 0 when no team is provided and no session exists', function () { + $limit = Team::serverLimit(); + + expect($limit)->toBe(0); +}); + +it('returns true for serverLimitReached when no team and no session', function () { + $result = Team::serverLimitReached(); + + expect($result)->toBeTrue(); +}); + +it('returns false for serverLimitReached when team has servers under limit', function () { + $team = Team::factory()->create(); + Server::factory()->create(['team_id' => $team->id]); + + $result = Team::serverLimitReached($team); + + // self_hosted has very high limit, 1 server is well under + expect($result)->toBeFalse(); +}); + +it('returns true for serverLimitReached when team has servers at limit', function () { + config()->set('constants.coolify.self_hosted', false); + + $team = Team::factory()->create(['custom_server_limit' => 1]); + Server::factory()->create(['team_id' => $team->id]); + + $result = Team::serverLimitReached($team); + + expect($result)->toBeTrue(); +}); diff --git a/tests/Unit/ProxyConfigRecoveryTest.php b/tests/Unit/ProxyConfigRecoveryTest.php index 219ec9bca..e10d899fe 100644 --- a/tests/Unit/ProxyConfigRecoveryTest.php +++ b/tests/Unit/ProxyConfigRecoveryTest.php @@ -10,20 +10,26 @@ Cache::spy(); }); -function mockServerWithDbConfig(?string $savedConfig): object +function mockServerWithDbConfig(?string $savedConfig, string $proxyType = 'TRAEFIK'): object { $proxyAttributes = Mockery::mock(SchemalessAttributes::class); $proxyAttributes->shouldReceive('get') ->with('last_saved_proxy_configuration') ->andReturn($savedConfig); + $proxyPath = match ($proxyType) { + 'CADDY' => '/data/coolify/proxy/caddy', + 'NGINX' => '/data/coolify/proxy/nginx', + default => '/data/coolify/proxy/', + }; + $server = Mockery::mock('App\Models\Server'); $server->shouldIgnoreMissing(); $server->shouldReceive('getAttribute')->with('proxy')->andReturn($proxyAttributes); $server->shouldReceive('getAttribute')->with('id')->andReturn(1); $server->shouldReceive('getAttribute')->with('name')->andReturn('Test Server'); - $server->shouldReceive('proxyType')->andReturn('TRAEFIK'); - $server->shouldReceive('proxyPath')->andReturn('/data/coolify/proxy'); + $server->shouldReceive('proxyType')->andReturn($proxyType); + $server->shouldReceive('proxyPath')->andReturn($proxyPath); return $server; } @@ -107,3 +113,61 @@ function mockServerWithDbConfig(?string $savedConfig): object expect($result)->toBe($savedConfig); }); + +it('rejects stored Traefik config when proxy type is CADDY', function () { + Log::swap(new \Illuminate\Log\LogManager(app())); + Log::spy(); + + $traefikConfig = "services:\n traefik:\n image: traefik:v3.6\n"; + $server = mockServerWithDbConfig($traefikConfig, 'CADDY'); + + // Config type mismatch should trigger regeneration, which will try + // backfillFromDisk (instant_remote_process) then generateDefault. + // Both will fail in test env, but the warning log proves mismatch was detected. + try { + GetProxyConfiguration::run($server); + } catch (\Throwable $e) { + // Expected — regeneration requires SSH/full server setup + } + + Log::shouldHaveReceived('warning') + ->withArgs(fn ($message) => str_contains($message, 'does not match current proxy type')) + ->once(); +}); + +it('rejects stored Caddy config when proxy type is TRAEFIK', function () { + Log::swap(new \Illuminate\Log\LogManager(app())); + Log::spy(); + + $caddyConfig = "services:\n caddy:\n image: lucaslorentz/caddy-docker-proxy:2.8-alpine\n"; + $server = mockServerWithDbConfig($caddyConfig, 'TRAEFIK'); + + try { + GetProxyConfiguration::run($server); + } catch (\Throwable $e) { + // Expected — regeneration requires SSH/full server setup + } + + Log::shouldHaveReceived('warning') + ->withArgs(fn ($message) => str_contains($message, 'does not match current proxy type')) + ->once(); +}); + +it('accepts stored Caddy config when proxy type is CADDY', function () { + $caddyConfig = "services:\n caddy:\n image: lucaslorentz/caddy-docker-proxy:2.8-alpine\n"; + $server = mockServerWithDbConfig($caddyConfig, 'CADDY'); + + $result = GetProxyConfiguration::run($server); + + expect($result)->toBe($caddyConfig); +}); + +it('accepts stored config when YAML parsing fails', function () { + $invalidYaml = "this: is: not: [valid yaml: {{{}}}"; + $server = mockServerWithDbConfig($invalidYaml, 'TRAEFIK'); + + // Invalid YAML should not block — configMatchesProxyType returns true on parse failure + $result = GetProxyConfiguration::run($server); + + expect($result)->toBe($invalidYaml); +}); diff --git a/tests/Unit/ServiceParserEnvVarPreservationTest.php b/tests/Unit/ServiceParserEnvVarPreservationTest.php index 3f56447dc..16a5ad676 100644 --- a/tests/Unit/ServiceParserEnvVarPreservationTest.php +++ b/tests/Unit/ServiceParserEnvVarPreservationTest.php @@ -4,7 +4,7 @@ * Unit tests to verify that Docker Compose environment variables * do not overwrite user-saved values on redeploy. * - * Regression test for GitHub issue #8885. + * Regression test for GitHub issues #8885 and #9136. */ it('uses firstOrCreate for simple variable references in serviceParser to preserve user values', function () { $parsersFile = file_get_contents(__DIR__.'/../../bootstrap/helpers/parsers.php'); @@ -14,8 +14,8 @@ // This is the key === parsedValue branch expect($parsersFile)->toContain( "// Simple variable reference (e.g. DATABASE_URL: \${DATABASE_URL})\n". - " // Use firstOrCreate to avoid overwriting user-saved values on redeploy\n". - ' $envVar = $resource->environment_variables()->firstOrCreate(' + " // Ensure the variable exists in DB for .env generation and UI display\n". + ' $resource->environment_variables()->firstOrCreate(' ); }); @@ -46,15 +46,15 @@ expect($count)->toBe(1, 'serviceParser should use firstOrCreate for simple variable refs without default'); }); -it('populates environment array with saved DB value instead of raw compose variable', function () { +it('does not replace self-referencing variable values in the environment array', function () { $parsersFile = file_get_contents(__DIR__.'/../../bootstrap/helpers/parsers.php'); - // After firstOrCreate, the environment should be populated with the DB value ($envVar->value) - // not the raw compose variable reference (e.g., ${DATABASE_URL}) - // This pattern should appear in both parsers for all variable reference types - expect($parsersFile)->toContain('// Add the variable to the environment using the saved DB value'); - expect($parsersFile)->toContain('$environment[$key->value()] = $envVar->value;'); - expect($parsersFile)->toContain('$environment[$content] = $envVar->value;'); + // Fix for #9136: self-referencing variables (KEY=${KEY}) must NOT have their ${VAR} + // reference replaced with the DB value in the compose environment section. + // Instead, the reference should stay intact so Docker Compose resolves from .env at deploy time. + // This prevents stale values when users update env vars without re-parsing compose. + expect($parsersFile)->toContain('Keep the ${VAR} reference in compose'); + expect($parsersFile)->not->toContain('$environment[$key->value()] = $envVar->value;'); }); it('does not use updateOrCreate with value null for user-editable environment variables', function () { diff --git a/tests/Unit/ValidHostnameTest.php b/tests/Unit/ValidHostnameTest.php index 859262c3e..6580a7c5d 100644 --- a/tests/Unit/ValidHostnameTest.php +++ b/tests/Unit/ValidHostnameTest.php @@ -21,6 +21,8 @@ 'subdomain' => 'web.app.example.com', 'max label length' => str_repeat('a', 63), 'max total length' => str_repeat('a', 63).'.'.str_repeat('b', 63).'.'.str_repeat('c', 63).'.'.str_repeat('d', 59), + 'uppercase hostname' => 'MyServer', + 'mixed case fqdn' => 'MyServer.Example.COM', ]); it('rejects invalid RFC 1123 hostnames', function (string $hostname, string $expectedError) { @@ -36,8 +38,7 @@ expect($failCalled)->toBeTrue(); expect($errorMessage)->toContain($expectedError); })->with([ - 'uppercase letters' => ['MyServer', 'lowercase letters (a-z), numbers (0-9), hyphens (-), and dots (.)'], - 'underscore' => ['my_server', 'lowercase letters (a-z), numbers (0-9), hyphens (-), and dots (.)'], + 'underscore' => ['my_server', 'letters (a-z, A-Z), numbers (0-9), hyphens (-), and dots (.)'], 'starts with hyphen' => ['-myserver', 'cannot start or end with a hyphen'], 'ends with hyphen' => ['myserver-', 'cannot start or end with a hyphen'], 'starts with dot' => ['.myserver', 'cannot start or end with a dot'], @@ -46,9 +47,9 @@ 'too long total' => [str_repeat('a', 254), 'must not exceed 253 characters'], 'label too long' => [str_repeat('a', 64), 'must be 1-63 characters'], 'empty label' => ['my..server', 'consecutive dots'], - 'special characters' => ['my@server', 'lowercase letters (a-z), numbers (0-9), hyphens (-), and dots (.)'], - 'space' => ['my server', 'lowercase letters (a-z), numbers (0-9), hyphens (-), and dots (.)'], - 'shell metacharacters' => ['my;server', 'lowercase letters (a-z), numbers (0-9), hyphens (-), and dots (.)'], + 'special characters' => ['my@server', 'letters (a-z, A-Z), numbers (0-9), hyphens (-), and dots (.)'], + 'space' => ['my server', 'letters (a-z, A-Z), numbers (0-9), hyphens (-), and dots (.)'], + 'shell metacharacters' => ['my;server', 'letters (a-z, A-Z), numbers (0-9), hyphens (-), and dots (.)'], ]); it('accepts empty hostname', function () { diff --git a/tests/Unit/ValidationPatternsTest.php b/tests/Unit/ValidationPatternsTest.php new file mode 100644 index 000000000..0da8f9a4d --- /dev/null +++ b/tests/Unit/ValidationPatternsTest.php @@ -0,0 +1,82 @@ +toBe(1); +})->with([ + 'simple name' => 'My Server', + 'name with hyphen' => 'my-server', + 'name with underscore' => 'my_server', + 'name with dot' => 'my.server', + 'name with slash' => 'my/server', + 'name with at sign' => 'user@host', + 'name with ampersand' => 'Tom & Jerry', + 'name with parentheses' => 'My Server (Production)', + 'name with hash' => 'Server #1', + 'name with comma' => 'Server, v2', + 'name with colon' => 'Server: Production', + 'name with plus' => 'C++ App', + 'unicode name' => 'Ünïcödé Sërvér', + 'unicode chinese' => '我的服务器', + 'numeric name' => '12345', + 'complex name' => 'App #3 (staging): v2.1+hotfix', +]); + +it('rejects names with dangerous characters', function (string $name) { + expect(preg_match(ValidationPatterns::NAME_PATTERN, $name))->toBe(0); +})->with([ + 'semicolon' => 'my;server', + 'pipe' => 'my|server', + 'dollar sign' => 'my$server', + 'backtick' => 'my`server', + 'backslash' => 'my\\server', + 'less than' => 'my 'my>server', + 'curly braces' => 'my{server}', + 'square brackets' => 'my[server]', + 'tilde' => 'my~server', + 'caret' => 'my^server', + 'question mark' => 'my?server', + 'percent' => 'my%server', + 'double quote' => 'my"server', + 'exclamation' => 'my!server', + 'asterisk' => 'my*server', +]); + +it('generates nameRules with correct defaults', function () { + $rules = ValidationPatterns::nameRules(); + + expect($rules)->toContain('required') + ->toContain('string') + ->toContain('min:3') + ->toContain('max:255') + ->toContain('regex:'.ValidationPatterns::NAME_PATTERN); +}); + +it('generates nullable nameRules when not required', function () { + $rules = ValidationPatterns::nameRules(required: false); + + expect($rules)->toContain('nullable') + ->not->toContain('required'); +}); + +it('generates application names that comply with NAME_PATTERN', function (string $repo, string $branch) { + $name = generate_application_name($repo, $branch, 'testcuid'); + + expect(preg_match(ValidationPatterns::NAME_PATTERN, $name))->toBe(1); +})->with([ + 'normal repo' => ['owner/my-app', 'main'], + 'repo with dots' => ['repo.with.dots', 'feat/branch'], + 'repo with plus' => ['C++ App', 'main'], + 'branch with parens' => ['my-app', 'fix(auth)-login'], + 'repo with exclamation' => ['my-app!', 'main'], + 'repo with brackets' => ['app[test]', 'develop'], +]); + +it('falls back to random name when repo produces empty name', function () { + $name = generate_application_name('!!!', 'main', 'testcuid'); + + expect(mb_strlen($name))->toBeGreaterThanOrEqual(3) + ->and(preg_match(ValidationPatterns::NAME_PATTERN, $name))->toBe(1); +}); diff --git a/versions.json b/versions.json index 57bb21869..af11ef4d3 100644 --- a/versions.json +++ b/versions.json @@ -1,7 +1,7 @@ { "coolify": { "v4": { - "version": "4.0.0-beta.470" + "version": "4.0.0-beta.471" }, "nightly": { "version": "4.0.0" @@ -13,7 +13,7 @@ "version": "1.0.11" }, "sentinel": { - "version": "0.0.20" + "version": "0.0.21" } }, "traefik": {