diff --git a/app/Actions/Database/StartClickhouse.php b/app/Actions/Database/StartClickhouse.php index 30cae71f1a..525e736c36 100644 --- a/app/Actions/Database/StartClickhouse.php +++ b/app/Actions/Database/StartClickhouse.php @@ -50,13 +50,9 @@ class StartClickhouse ], ], 'labels' => defaultDatabaseLabels($this->database)->toArray(), - 'healthcheck' => [ - 'test' => ['CMD', 'clickhouse-client', '--user', (string) $this->database->clickhouse_admin_user, '--password', (string) $this->database->clickhouse_admin_password, '--query', 'SELECT 1'], - 'interval' => '5s', - 'timeout' => '5s', - 'retries' => 10, - 'start_period' => '5s', - ], + 'healthcheck' => $this->database->healthCheckConfiguration([ + 'CMD', 'clickhouse-client', '--user', (string) $this->database->clickhouse_admin_user, '--password', (string) $this->database->clickhouse_admin_password, '--query', 'SELECT 1', + ]), 'mem_limit' => $this->database->limits_memory, 'memswap_limit' => $this->database->limits_memory_swap, 'mem_swappiness' => $this->database->limits_memory_swappiness, @@ -98,6 +94,9 @@ class StartClickhouse $docker_run_options = convertDockerRunToCompose($this->database->custom_docker_run_options); $docker_compose = generateCustomDockerRunOptionsForDatabases($docker_run_options, $docker_compose, $container_name, $this->database->destination->network); + if (! $this->database->isHealthcheckEnabled()) { + unset($docker_compose['services'][$container_name]['healthcheck']); + } $docker_compose = Yaml::dump($docker_compose, 10); $docker_compose_base64 = base64_encode($docker_compose); $this->commands[] = "echo '{$docker_compose_base64}' | base64 -d | tee $this->configuration_dir/docker-compose.yml > /dev/null"; diff --git a/app/Actions/Database/StartDragonfly.php b/app/Actions/Database/StartDragonfly.php index addc30be41..b78a0987d9 100644 --- a/app/Actions/Database/StartDragonfly.php +++ b/app/Actions/Database/StartDragonfly.php @@ -106,13 +106,9 @@ class StartDragonfly $this->database->destination->network, ], 'labels' => defaultDatabaseLabels($this->database)->toArray(), - 'healthcheck' => [ - 'test' => ['CMD', 'redis-cli', '-a', (string) $this->database->dragonfly_password, 'ping'], - 'interval' => '5s', - 'timeout' => '5s', - 'retries' => 10, - 'start_period' => '5s', - ], + 'healthcheck' => $this->database->healthCheckConfiguration([ + 'CMD', 'redis-cli', '-a', (string) $this->database->dragonfly_password, 'ping', + ]), 'mem_limit' => $this->database->limits_memory, 'memswap_limit' => $this->database->limits_memory_swap, 'mem_swappiness' => $this->database->limits_memory_swappiness, @@ -182,6 +178,9 @@ class StartDragonfly $docker_run_options = convertDockerRunToCompose($this->database->custom_docker_run_options); $docker_compose = generateCustomDockerRunOptionsForDatabases($docker_run_options, $docker_compose, $container_name, $this->database->destination->network); + if (! $this->database->isHealthcheckEnabled()) { + unset($docker_compose['services'][$container_name]['healthcheck']); + } $docker_compose = Yaml::dump($docker_compose, 10); $docker_compose_base64 = base64_encode($docker_compose); $this->commands[] = "echo '{$docker_compose_base64}' | base64 -d | tee $this->configuration_dir/docker-compose.yml > /dev/null"; diff --git a/app/Actions/Database/StartKeydb.php b/app/Actions/Database/StartKeydb.php index e59d6f697c..89258fe249 100644 --- a/app/Actions/Database/StartKeydb.php +++ b/app/Actions/Database/StartKeydb.php @@ -108,13 +108,9 @@ class StartKeydb $this->database->destination->network, ], 'labels' => defaultDatabaseLabels($this->database)->toArray(), - 'healthcheck' => [ - 'test' => ['CMD', 'keydb-cli', '--pass', (string) $this->database->keydb_password, 'ping'], - 'interval' => '5s', - 'timeout' => '5s', - 'retries' => 10, - 'start_period' => '5s', - ], + 'healthcheck' => $this->database->healthCheckConfiguration([ + 'CMD', 'keydb-cli', '--pass', (string) $this->database->keydb_password, 'ping', + ]), 'mem_limit' => $this->database->limits_memory, 'memswap_limit' => $this->database->limits_memory_swap, 'mem_swappiness' => $this->database->limits_memory_swappiness, @@ -197,6 +193,9 @@ class StartKeydb // Add custom docker run options $docker_run_options = convertDockerRunToCompose($this->database->custom_docker_run_options); $docker_compose = generateCustomDockerRunOptionsForDatabases($docker_run_options, $docker_compose, $container_name, $this->database->destination->network); + if (! $this->database->isHealthcheckEnabled()) { + unset($docker_compose['services'][$container_name]['healthcheck']); + } $docker_compose = Yaml::dump($docker_compose, 10); $docker_compose_base64 = base64_encode($docker_compose); $this->commands[] = "echo '{$docker_compose_base64}' | base64 -d | tee $this->configuration_dir/docker-compose.yml > /dev/null"; diff --git a/app/Actions/Database/StartMariadb.php b/app/Actions/Database/StartMariadb.php index ceb1e8b859..2e8faea9a3 100644 --- a/app/Actions/Database/StartMariadb.php +++ b/app/Actions/Database/StartMariadb.php @@ -103,13 +103,9 @@ class StartMariadb $this->database->destination->network, ], 'labels' => defaultDatabaseLabels($this->database)->toArray(), - 'healthcheck' => [ - 'test' => ['CMD', 'healthcheck.sh', '--connect', '--innodb_initialized'], - 'interval' => '5s', - 'timeout' => '5s', - 'retries' => 10, - 'start_period' => '5s', - ], + 'healthcheck' => $this->database->healthCheckConfiguration([ + 'CMD', 'healthcheck.sh', '--connect', '--innodb_initialized', + ]), 'mem_limit' => $this->database->limits_memory, 'memswap_limit' => $this->database->limits_memory_swap, 'mem_swappiness' => $this->database->limits_memory_swappiness, @@ -202,6 +198,9 @@ class StartMariadb ]; } + if (! $this->database->isHealthcheckEnabled()) { + unset($docker_compose['services'][$container_name]['healthcheck']); + } $docker_compose = Yaml::dump($docker_compose, 10); $docker_compose_base64 = base64_encode($docker_compose); $this->commands[] = "echo '{$docker_compose_base64}' | base64 -d | tee $this->configuration_dir/docker-compose.yml > /dev/null"; diff --git a/app/Actions/Database/StartMongodb.php b/app/Actions/Database/StartMongodb.php index c797897189..80ec812a1f 100644 --- a/app/Actions/Database/StartMongodb.php +++ b/app/Actions/Database/StartMongodb.php @@ -109,17 +109,11 @@ class StartMongodb $this->database->destination->network, ], 'labels' => defaultDatabaseLabels($this->database)->toArray(), - 'healthcheck' => [ - 'test' => [ - 'CMD', - 'echo', - 'ok', - ], - 'interval' => '5s', - 'timeout' => '5s', - 'retries' => 10, - 'start_period' => '5s', - ], + 'healthcheck' => $this->database->healthCheckConfiguration([ + 'CMD', + 'echo', + 'ok', + ]), 'mem_limit' => $this->database->limits_memory, 'memswap_limit' => $this->database->limits_memory_swap, 'mem_swappiness' => $this->database->limits_memory_swappiness, @@ -253,6 +247,9 @@ class StartMongodb $docker_compose['services'][$container_name]['command'] = $commandParts; } + if (! $this->database->isHealthcheckEnabled()) { + unset($docker_compose['services'][$container_name]['healthcheck']); + } $docker_compose = Yaml::dump($docker_compose, 10); $docker_compose_base64 = base64_encode($docker_compose); $this->commands[] = "echo '{$docker_compose_base64}' | base64 -d | tee $this->configuration_dir/docker-compose.yml > /dev/null"; diff --git a/app/Actions/Database/StartMysql.php b/app/Actions/Database/StartMysql.php index 0394d50b66..0445bddcd5 100644 --- a/app/Actions/Database/StartMysql.php +++ b/app/Actions/Database/StartMysql.php @@ -103,13 +103,9 @@ class StartMysql $this->database->destination->network, ], 'labels' => defaultDatabaseLabels($this->database)->toArray(), - 'healthcheck' => [ - 'test' => ['CMD', 'mysqladmin', 'ping', '-h', 'localhost', '-u', 'root', "-p{$this->database->mysql_root_password}"], - 'interval' => '5s', - 'timeout' => '5s', - 'retries' => 10, - 'start_period' => '5s', - ], + 'healthcheck' => $this->database->healthCheckConfiguration([ + 'CMD', 'mysqladmin', 'ping', '-h', 'localhost', '-u', 'root', "-p{$this->database->mysql_root_password}", + ]), 'mem_limit' => $this->database->limits_memory, 'memswap_limit' => $this->database->limits_memory_swap, 'mem_swappiness' => $this->database->limits_memory_swappiness, @@ -203,6 +199,9 @@ class StartMysql ]; } + if (! $this->database->isHealthcheckEnabled()) { + unset($docker_compose['services'][$container_name]['healthcheck']); + } $docker_compose = Yaml::dump($docker_compose, 10); $docker_compose_base64 = base64_encode($docker_compose); $this->commands[] = "echo '{$docker_compose_base64}' | base64 -d | tee $this->configuration_dir/docker-compose.yml > /dev/null"; diff --git a/app/Actions/Database/StartPostgresql.php b/app/Actions/Database/StartPostgresql.php index da8b5dc4ea..ae7ae98608 100644 --- a/app/Actions/Database/StartPostgresql.php +++ b/app/Actions/Database/StartPostgresql.php @@ -110,13 +110,9 @@ class StartPostgresql $this->database->destination->network, ], 'labels' => defaultDatabaseLabels($this->database)->toArray(), - 'healthcheck' => [ - 'test' => ['CMD', 'psql', '-U', (string) $this->database->postgres_user, '-d', (string) $this->database->postgres_db, '-c', 'SELECT 1'], - 'interval' => '5s', - 'timeout' => '5s', - 'retries' => 10, - 'start_period' => '5s', - ], + 'healthcheck' => $this->database->healthCheckConfiguration([ + 'CMD', 'psql', '-U', (string) $this->database->postgres_user, '-d', (string) $this->database->postgres_db, '-c', 'SELECT 1', + ]), 'mem_limit' => $this->database->limits_memory, 'memswap_limit' => $this->database->limits_memory_swap, 'mem_swappiness' => $this->database->limits_memory_swappiness, @@ -213,6 +209,9 @@ class StartPostgresql $docker_compose['services'][$container_name]['command'] = $command; } + if (! $this->database->isHealthcheckEnabled()) { + unset($docker_compose['services'][$container_name]['healthcheck']); + } $docker_compose = Yaml::dump($docker_compose, 10); $docker_compose_base64 = base64_encode($docker_compose); $this->commands[] = "echo '{$docker_compose_base64}' | base64 -d | tee $this->configuration_dir/docker-compose.yml > /dev/null"; diff --git a/app/Actions/Database/StartRedis.php b/app/Actions/Database/StartRedis.php index c31b099e4f..64b434821b 100644 --- a/app/Actions/Database/StartRedis.php +++ b/app/Actions/Database/StartRedis.php @@ -105,17 +105,11 @@ class StartRedis $this->database->destination->network, ], 'labels' => defaultDatabaseLabels($this->database)->toArray(), - 'healthcheck' => [ - 'test' => [ - 'CMD-SHELL', - 'redis-cli', - 'ping', - ], - 'interval' => '5s', - 'timeout' => '5s', - 'retries' => 10, - 'start_period' => '5s', - ], + 'healthcheck' => $this->database->healthCheckConfiguration([ + 'CMD-SHELL', + 'redis-cli', + 'ping', + ]), 'mem_limit' => $this->database->limits_memory, 'memswap_limit' => $this->database->limits_memory_swap, 'mem_swappiness' => $this->database->limits_memory_swappiness, @@ -194,6 +188,9 @@ class StartRedis $docker_run_options = convertDockerRunToCompose($this->database->custom_docker_run_options); $docker_compose = generateCustomDockerRunOptionsForDatabases($docker_run_options, $docker_compose, $container_name, $this->database->destination->network); + if (! $this->database->isHealthcheckEnabled()) { + unset($docker_compose['services'][$container_name]['healthcheck']); + } $docker_compose = Yaml::dump($docker_compose, 10); $docker_compose_base64 = base64_encode($docker_compose); $this->commands[] = "echo '{$docker_compose_base64}' | base64 -d | tee $this->configuration_dir/docker-compose.yml > /dev/null"; diff --git a/app/Actions/Fortify/CreateNewUser.php b/app/Actions/Fortify/CreateNewUser.php index 7ea6a871e2..cddf663897 100644 --- a/app/Actions/Fortify/CreateNewUser.php +++ b/app/Actions/Fortify/CreateNewUser.php @@ -2,6 +2,7 @@ namespace App\Actions\Fortify; +use App\Models\Team; use App\Models\User; use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Validator; @@ -44,7 +45,10 @@ class CreateNewUser implements CreatesNewUsers 'password' => Hash::make($input['password']), ]); $user->save(); - $team = $user->teams()->first(); + $team = $user->teams()->first() ?? Team::find(0); + if ($team !== null && ! $user->teams()->where('team_id', $team->id)->exists()) { + $user->teams()->attach($team, ['role' => 'owner']); + } // Disable registration after first user is created $settings = instanceSettings(); diff --git a/app/Actions/Server/ResourcesCheck.php b/app/Actions/Server/ResourcesCheck.php deleted file mode 100644 index e6b90ba380..0000000000 --- a/app/Actions/Server/ResourcesCheck.php +++ /dev/null @@ -1,41 +0,0 @@ -subSeconds($seconds))->update(['status' => 'exited']); - ServiceApplication::where('last_online_at', '<', now()->subSeconds($seconds))->update(['status' => 'exited']); - ServiceDatabase::where('last_online_at', '<', now()->subSeconds($seconds))->update(['status' => 'exited']); - StandalonePostgresql::where('last_online_at', '<', now()->subSeconds($seconds))->update(['status' => 'exited']); - StandaloneRedis::where('last_online_at', '<', now()->subSeconds($seconds))->update(['status' => 'exited']); - StandaloneMongodb::where('last_online_at', '<', now()->subSeconds($seconds))->update(['status' => 'exited']); - StandaloneMysql::where('last_online_at', '<', now()->subSeconds($seconds))->update(['status' => 'exited']); - StandaloneMariadb::where('last_online_at', '<', now()->subSeconds($seconds))->update(['status' => 'exited']); - StandaloneKeydb::where('last_online_at', '<', now()->subSeconds($seconds))->update(['status' => 'exited']); - StandaloneDragonfly::where('last_online_at', '<', now()->subSeconds($seconds))->update(['status' => 'exited']); - StandaloneClickhouse::where('last_online_at', '<', now()->subSeconds($seconds))->update(['status' => 'exited']); - } catch (\Throwable $e) { - return handleError($e); - } - } -} diff --git a/app/Actions/Service/RestartService.php b/app/Actions/Service/RestartService.php index d38ef54d66..6acd3b0a43 100644 --- a/app/Actions/Service/RestartService.php +++ b/app/Actions/Service/RestartService.php @@ -13,8 +13,10 @@ class RestartService public function handle(Service $service, bool $pullLatestImages) { - StopService::run($service); - - return StartService::run($service, $pullLatestImages); + return StartService::run( + service: $service, + pullLatestImages: $pullLatestImages, + stopBeforeStart: true, + ); } } diff --git a/app/Actions/Service/StartService.php b/app/Actions/Service/StartService.php index d3d99ff780..89817506dd 100644 --- a/app/Actions/Service/StartService.php +++ b/app/Actions/Service/StartService.php @@ -19,7 +19,7 @@ class StartService public function handle(Service $service, bool $pullLatestImages = false, bool $stopBeforeStart = false) { $service->parse(); - if ($stopBeforeStart) { + if ($this->shouldStopBeforeStarting($pullLatestImages, $stopBeforeStart)) { StopService::run(service: $service, dockerCleanup: false); } $service->saveComposeConfigs(); @@ -53,4 +53,9 @@ class StartService return remote_process($commands, $service->server, type_uuid: $service->uuid, callEventOnFinish: 'ServiceStatusChanged'); } + + private function shouldStopBeforeStarting(bool $pullLatestImages, bool $stopBeforeStart): bool + { + return $stopBeforeStart && ! $pullLatestImages; + } } diff --git a/app/Console/Commands/CleanupUnreachableServers.php b/app/Console/Commands/CleanupUnreachableServers.php index 09563a2c39..666e98a182 100644 --- a/app/Console/Commands/CleanupUnreachableServers.php +++ b/app/Console/Commands/CleanupUnreachableServers.php @@ -18,9 +18,13 @@ class CleanupUnreachableServers extends Command if ($servers->count() > 0) { foreach ($servers as $server) { echo "Cleanup unreachable server ($server->id) with name $server->name"; - $server->update([ - 'ip' => '1.2.3.4', - ]); + if (isCloud()) { + $server->update([ + 'ip' => '1.2.3.4', + ]); + } else { + $server->forceDisableServer(); + } } } } diff --git a/app/Console/Commands/SyncBunny.php b/app/Console/Commands/SyncBunny.php index 9ac3371e0d..d6d77f22e5 100644 --- a/app/Console/Commands/SyncBunny.php +++ b/app/Console/Commands/SyncBunny.php @@ -16,7 +16,7 @@ class SyncBunny extends Command * * @var string */ - protected $signature = 'sync:bunny {--templates} {--release} {--github-releases} {--github-versions} {--nightly}'; + protected $signature = 'sync:bunny {--templates} {--release} {--nightly}'; /** * The console command description. @@ -25,650 +25,6 @@ class SyncBunny extends Command */ protected $description = 'Sync files to BunnyCDN'; - /** - * Fetch GitHub releases and sync to GitHub repository - */ - private function syncReleasesToGitHubRepo(): bool - { - $this->info('Fetching releases from GitHub...'); - try { - $response = Http::timeout(30) - ->get('https://api.github.com/repos/coollabsio/coolify/releases', [ - 'per_page' => 30, // Fetch more releases for better changelog - ]); - - if (! $response->successful()) { - $this->error('Failed to fetch releases from GitHub: '.$response->status()); - - return false; - } - - $releases = $response->json(); - $timestamp = time(); - $tmpDir = sys_get_temp_dir().'/coolify-cdn-'.$timestamp; - $branchName = 'update-releases-'.$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; - } - - // Write releases.json - $this->info('Writing releases.json...'); - $releasesPath = "$tmpDir/json/releases.json"; - $releasesDir = dirname($releasesPath); - - // Ensure directory exists - if (! is_dir($releasesDir)) { - $this->info("Creating directory: $releasesDir"); - if (! mkdir($releasesDir, 0755, true)) { - $this->error("Failed to create directory: $releasesDir"); - exec('rm -rf '.escapeshellarg($tmpDir)); - - return false; - } - } - - $jsonContent = json_encode($releases, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); - $bytesWritten = file_put_contents($releasesPath, $jsonContent); - - if ($bytesWritten === false) { - $this->error("Failed to write releases.json to: $releasesPath"); - $this->error('Possible reasons: permission denied or disk full.'); - exec('rm -rf '.escapeshellarg($tmpDir)); - - return false; - } - - // Stage and commit - $this->info('Committing changes...'); - $output = []; - exec('cd '.escapeshellarg($tmpDir).' && git add json/releases.json 2>&1', $output, $returnCode); - if ($returnCode !== 0) { - $this->error('Failed to stage changes: '.implode("\n", $output)); - exec('rm -rf '.escapeshellarg($tmpDir)); - - return false; - } - - $this->info('Checking for changes...'); - $statusOutput = []; - exec('cd '.escapeshellarg($tmpDir).' && git status --porcelain json/releases.json 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('Releases are already up to date. No changes to commit.'); - exec('rm -rf '.escapeshellarg($tmpDir)); - - return true; - } - - $commitMessage = 'Update releases.json with latest releases - '.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 releases.json - '.date('Y-m-d H:i:s'); - $prBody = 'Automated update of releases.json with latest '.count($releases).' releases from GitHub API'; - $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 Output: '.implode("\n", $output)); - } - $this->info('Total releases synced: '.count($releases)); - - return true; - } catch (\Throwable $e) { - $this->error('Error syncing releases: '.$e->getMessage()); - - return false; - } - } - - /** - * Sync both releases.json and versions.json to GitHub repository in one PR - */ - private function syncReleasesAndVersionsToGitHubRepo(string $versionsLocation, bool $nightly = false): bool - { - $this->info('Syncing releases.json and versions.json to GitHub repository...'); - try { - // 1. Fetch releases from GitHub API - $this->info('Fetching releases from GitHub API...'); - $response = Http::timeout(30) - ->get('https://api.github.com/repos/coollabsio/coolify/releases', [ - 'per_page' => 30, - ]); - - if (! $response->successful()) { - $this->error('Failed to fetch releases from GitHub: '.$response->status()); - - return false; - } - - $releases = $response->json(); - - // 2. Read versions.json - if (! file_exists($versionsLocation)) { - $this->error("versions.json not found at: $versionsLocation"); - - return false; - } - - $file = file_get_contents($versionsLocation); - $versionsJson = json_decode($file, true); - $actualVersion = data_get($versionsJson, 'coolify.v4.version'); - - $timestamp = time(); - $tmpDir = sys_get_temp_dir().'/coolify-cdn-combined-'.$timestamp; - $branchName = 'update-releases-and-versions-'.$timestamp; - $versionsTargetPath = $nightly ? 'json/versions-nightly.json' : 'json/versions.json'; - - // 3. 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; - } - - // 4. 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; - } - - // 5. Write releases.json - $this->info('Writing releases.json...'); - $releasesPath = "$tmpDir/json/releases.json"; - $releasesDir = dirname($releasesPath); - - if (! is_dir($releasesDir)) { - if (! mkdir($releasesDir, 0755, true)) { - $this->error("Failed to create directory: $releasesDir"); - exec('rm -rf '.escapeshellarg($tmpDir)); - - return false; - } - } - - $releasesJsonContent = json_encode($releases, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); - if (file_put_contents($releasesPath, $releasesJsonContent) === false) { - $this->error("Failed to write releases.json to: $releasesPath"); - exec('rm -rf '.escapeshellarg($tmpDir)); - - return false; - } - - // 6. Write versions.json - $this->info('Writing versions.json...'); - $versionsPath = "$tmpDir/$versionsTargetPath"; - $versionsDir = dirname($versionsPath); - - if (! is_dir($versionsDir)) { - if (! mkdir($versionsDir, 0755, true)) { - $this->error("Failed to create directory: $versionsDir"); - exec('rm -rf '.escapeshellarg($tmpDir)); - - return false; - } - } - - $versionsJsonContent = json_encode($versionsJson, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); - if (file_put_contents($versionsPath, $versionsJsonContent) === false) { - $this->error("Failed to write versions.json to: $versionsPath"); - exec('rm -rf '.escapeshellarg($tmpDir)); - - return false; - } - - // 7. Stage both files - $this->info('Staging changes...'); - $output = []; - exec('cd '.escapeshellarg($tmpDir).' && git add json/releases.json '.escapeshellarg($versionsTargetPath).' 2>&1', $output, $returnCode); - if ($returnCode !== 0) { - $this->error('Failed to stage changes: '.implode("\n", $output)); - exec('rm -rf '.escapeshellarg($tmpDir)); - - return false; - } - - // 8. 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('Both files are already up to date. No changes to commit.'); - exec('rm -rf '.escapeshellarg($tmpDir)); - - return true; - } - - // 9. Commit changes - $envLabel = $nightly ? 'NIGHTLY' : 'PRODUCTION'; - $commitMessage = "Update releases.json and $envLabel versions.json to $actualVersion - ".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; - } - - // 10. 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; - } - - // 11. Create pull request - $this->info('Creating pull request...'); - $prTitle = "Update releases.json and $envLabel versions.json to $actualVersion - ".date('Y-m-d H:i:s'); - $prBody = "Automated update:\n- releases.json with latest ".count($releases)." releases from GitHub API\n- $envLabel versions.json to version $actualVersion"; - $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); - - // 12. 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("Version synced: $actualVersion"); - $this->info('Total releases synced: '.count($releases)); - - return true; - } catch (\Throwable $e) { - $this->error('Error syncing to GitHub: '.$e->getMessage()); - - return false; - } - } - - /** - * 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 - */ - private function syncVersionsToGitHubRepo(string $versionsLocation, bool $nightly = false): bool - { - $this->info('Syncing versions.json to GitHub repository...'); - try { - if (! file_exists($versionsLocation)) { - $this->error("versions.json not found at: $versionsLocation"); - - return false; - } - - $file = file_get_contents($versionsLocation); - $json = json_decode($file, true); - $actualVersion = data_get($json, 'coolify.v4.version'); - - $timestamp = time(); - $tmpDir = sys_get_temp_dir().'/coolify-cdn-versions-'.$timestamp; - $branchName = 'update-versions-'.$timestamp; - $targetPath = $nightly ? 'json/versions-nightly.json' : 'json/versions.json'; - - // Clone the repository - $this->info('Cloning coolify-cdn repository...'); - 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; - } - - // Write versions.json - $this->info('Writing versions.json...'); - $versionsPath = "$tmpDir/$targetPath"; - $versionsDir = dirname($versionsPath); - - // Ensure directory exists - if (! is_dir($versionsDir)) { - $this->info("Creating directory: $versionsDir"); - if (! mkdir($versionsDir, 0755, true)) { - $this->error("Failed to create directory: $versionsDir"); - exec('rm -rf '.escapeshellarg($tmpDir)); - - return false; - } - } - - $jsonContent = json_encode($json, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); - $bytesWritten = file_put_contents($versionsPath, $jsonContent); - - if ($bytesWritten === false) { - $this->error("Failed to write versions.json to: $versionsPath"); - $this->error('Possible reasons: permission denied or disk full.'); - exec('rm -rf '.escapeshellarg($tmpDir)); - - return false; - } - - // Stage and commit - $this->info('Committing changes...'); - $output = []; - exec('cd '.escapeshellarg($tmpDir).' && git add '.escapeshellarg($targetPath).' 2>&1', $output, $returnCode); - if ($returnCode !== 0) { - $this->error('Failed to stage changes: '.implode("\n", $output)); - exec('rm -rf '.escapeshellarg($tmpDir)); - - return false; - } - - $this->info('Checking for changes...'); - $statusOutput = []; - exec('cd '.escapeshellarg($tmpDir).' && git status --porcelain '.escapeshellarg($targetPath).' 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('versions.json is already up to date. No changes to commit.'); - exec('rm -rf '.escapeshellarg($tmpDir)); - - return true; - } - - $envLabel = $nightly ? 'NIGHTLY' : 'PRODUCTION'; - $commitMessage = "Update $envLabel versions.json to $actualVersion - ".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 versions.json to $actualVersion - ".date('Y-m-d H:i:s'); - $prBody = "Automated update of $envLabel versions.json to version $actualVersion"; - $output = []; - $prCommand = 'gh pr create --repo coollabsio/coolify-cdn --title '.escapeshellarg($prTitle).' --body '.escapeshellarg($prBody).' --base main --head '.escapeshellarg($branchName).' 2>&1'; - 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("Version synced: $actualVersion"); - - return true; - } catch (\Throwable $e) { - $this->error('Error syncing versions.json: '.$e->getMessage()); - - return false; - } - } - /** * Execute the console command. */ @@ -677,8 +33,6 @@ class SyncBunny extends Command $that = $this; $only_template = $this->option('templates'); $only_version = $this->option('release'); - $only_github_releases = $this->option('github-releases'); - $only_github_versions = $this->option('github-versions'); $nightly = $this->option('nightly'); $bunny_cdn = 'https://cdn.coollabs.io'; $bunny_cdn_path = 'coolify'; @@ -736,30 +90,11 @@ class SyncBunny extends Command $install_script_location = "$parent_dir/other/nightly/$install_script"; $versions_location = "$parent_dir/other/nightly/$versions"; } - if (! $only_template && ! $only_version && ! $only_github_releases && ! $only_github_versions) { + if (! $only_template && ! $only_version) { $envLabel = $nightly ? 'NIGHTLY' : 'PRODUCTION'; - $this->info("About to sync $envLabel files to BunnyCDN and create a GitHub PR for coolify-cdn."); + $this->info("About to sync $envLabel files to BunnyCDN."); $this->newLine(); - // Build file mapping for diff - if ($nightly) { - $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 { - $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", @@ -812,44 +147,6 @@ class SyncBunny extends Command } } - // 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) { @@ -881,9 +178,9 @@ class SyncBunny extends Command return; } elseif ($only_version) { if ($nightly) { - $this->info('About to sync NIGHTLY versions.json to BunnyCDN and create GitHub PR.'); + $this->info('About to sync NIGHTLY versions.json to BunnyCDN.'); } else { - $this->info('About to sync PRODUCTION versions.json to BunnyCDN and create GitHub PR.'); + $this->info('About to sync PRODUCTION versions.json to BunnyCDN.'); } $file = file_get_contents($versions_location); $json = json_decode($file, true); @@ -891,8 +188,7 @@ class SyncBunny extends Command $this->info("Version: {$actual_version}"); $this->info('This will:'); - $this->info(' 1. Sync versions.json to BunnyCDN (deprecated but still supported)'); - $this->info(' 2. Create ONE GitHub PR with both releases.json and versions.json'); + $this->info(' 1. Sync versions.json to BunnyCDN'); $this->newLine(); $confirmed = confirm('Are you sure you want to proceed?'); @@ -900,8 +196,7 @@ class SyncBunny extends Command return; } - // 1. Sync versions.json to BunnyCDN (deprecated but still needed) - $this->info('Step 1/2: Syncing versions.json to BunnyCDN...'); + $this->info('Syncing versions.json to BunnyCDN...'); Http::pool(fn (Pool $pool) => [ $pool->storage(fileName: $versions_location)->put("/$bunny_cdn_storage_name/$bunny_cdn_path/$versions"), $pool->purge("$bunny_cdn/$bunny_cdn_path/$versions"), @@ -909,46 +204,8 @@ class SyncBunny extends Command $this->info('✓ versions.json uploaded & purged to BunnyCDN'); $this->newLine(); - // 2. Create GitHub PR with both releases.json and versions.json - $this->info('Step 2/2: Creating GitHub PR with releases.json and versions.json...'); - $githubSuccess = $this->syncReleasesAndVersionsToGitHubRepo($versions_location, $nightly); - if ($githubSuccess) { - $this->info('✓ GitHub PR created successfully with both files'); - } else { - $this->error('✗ Failed to create GitHub PR'); - } - $this->newLine(); - $this->info('=== Summary ==='); $this->info('BunnyCDN sync: ✓ Complete'); - $this->info('GitHub PR: '.($githubSuccess ? '✓ Created (releases.json + versions.json)' : '✗ Failed')); - - return; - } elseif ($only_github_releases) { - $this->info('About to sync GitHub releases to GitHub repository.'); - $confirmed = confirm('Are you sure you want to sync GitHub releases?'); - if (! $confirmed) { - return; - } - - // Sync releases to GitHub repository - $this->syncReleasesToGitHubRepo(); - - return; - } elseif ($only_github_versions) { - $envLabel = $nightly ? 'NIGHTLY' : 'PRODUCTION'; - $file = file_get_contents($versions_location); - $json = json_decode($file, true); - $actual_version = data_get($json, 'coolify.v4.version'); - - $this->info("About to sync $envLabel versions.json ($actual_version) to GitHub repository."); - $confirmed = confirm('Are you sure you want to sync versions.json via GitHub PR?'); - if (! $confirmed) { - return; - } - - // Sync versions.json to GitHub repository - $this->syncVersionsToGitHubRepo($versions_location, $nightly); return; } @@ -970,31 +227,8 @@ class SyncBunny extends Command $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/Console/Kernel.php b/app/Console/Kernel.php index 665553fcb3..e971058365 100644 --- a/app/Console/Kernel.php +++ b/app/Console/Kernel.php @@ -8,7 +8,6 @@ use App\Jobs\CheckHelperImageJob; use App\Jobs\CheckTraefikVersionJob; use App\Jobs\CleanupInstanceStuffsJob; use App\Jobs\CleanupOrphanedPreviewContainersJob; -use App\Jobs\CleanupStaleMultiplexedConnections; use App\Jobs\PullChangelog; use App\Jobs\PullTemplatesFromCDN; use App\Jobs\RegenerateSslCertJob; @@ -41,7 +40,6 @@ class Kernel extends ConsoleKernel $this->instanceTimezone = config('app.timezone'); } - $this->scheduleInstance->job(new CleanupStaleMultiplexedConnections)->hourly()->onOneServer(); $this->scheduleInstance->command('cleanup:redis --clear-locks')->daily(); $this->scheduleInstance->command('sanctum:prune-expired --hours=1')->hourly()->onOneServer(); $this->scheduleInstance->job(new ApiTokenExpirationWarningJob)->hourly()->onOneServer(); diff --git a/app/Helpers/SshMultiplexingHelper.php b/app/Helpers/SshMultiplexingHelper.php index cf92deb2a7..021ac3608d 100644 --- a/app/Helpers/SshMultiplexingHelper.php +++ b/app/Helpers/SshMultiplexingHelper.php @@ -63,10 +63,10 @@ class SshMultiplexingHelper $scpCommand .= self::getCommonSshOptions($server, $sshKeyLocation, self::getConnectionTimeout($server), config('constants.ssh.server_interval'), isScp: true); if ($server->isIpv6()) { - return $scpCommand."{$source} ".escapeshellarg($server->user).'@['.escapeshellarg($server->ip)."]:{$dest}"; + return $scpCommand.escapeshellarg($source).' '.escapeshellarg($server->user).'@['.escapeshellarg($server->ip).']:'.escapeshellarg($dest); } - return $scpCommand."{$source} ".self::escapedUserAtHost($server).":{$dest}"; + return $scpCommand.escapeshellarg($source).' '.self::escapedUserAtHost($server).':'.escapeshellarg($dest); } public static function generateSshCommand(Server $server, string $command, bool $disableMultiplexing = false): string diff --git a/app/Http/Controllers/Api/DatabasesController.php b/app/Http/Controllers/Api/DatabasesController.php index dc9b6f5b53..bceef4d39d 100644 --- a/app/Http/Controllers/Api/DatabasesController.php +++ b/app/Http/Controllers/Api/DatabasesController.php @@ -299,6 +299,11 @@ class DatabasesController extends Controller 'mysql_user' => ['type' => 'string', 'description' => 'MySQL user'], 'mysql_database' => ['type' => 'string', 'description' => 'MySQL database'], 'mysql_conf' => ['type' => 'string', 'description' => 'MySQL conf'], + 'health_check_enabled' => ['type' => 'boolean', 'description' => 'Enable the database healthcheck probe.', 'default' => true], + 'health_check_interval' => ['type' => 'integer', 'description' => 'Healthcheck interval in seconds.', 'minimum' => 1, 'default' => 15], + 'health_check_timeout' => ['type' => 'integer', 'description' => 'Healthcheck timeout in seconds.', 'minimum' => 1, 'default' => 5], + 'health_check_retries' => ['type' => 'integer', 'description' => 'Healthcheck retries count.', 'minimum' => 1, 'default' => 5], + 'health_check_start_period' => ['type' => 'integer', 'description' => 'Healthcheck start period in seconds.', 'minimum' => 0, 'default' => 5], ], ), ) @@ -565,9 +570,17 @@ class DatabasesController extends Controller } break; } + $allowedFields = array_merge($allowedFields, ['health_check_enabled', 'health_check_interval', 'health_check_timeout', 'health_check_retries', 'health_check_start_period']); + $healthCheckValidator = customApiValidator($request->all(), [ + 'health_check_enabled' => 'boolean', + 'health_check_interval' => 'integer|min:1', + 'health_check_timeout' => 'integer|min:1', + 'health_check_retries' => 'integer|min:1', + 'health_check_start_period' => 'integer|min:0', + ]); $extraFields = array_diff(array_keys($request->all()), $allowedFields); - if ($validator->fails() || ! empty($extraFields)) { - $errors = $validator->errors(); + if ($validator->fails() || $healthCheckValidator->fails() || ! empty($extraFields)) { + $errors = $validator->errors()->merge($healthCheckValidator->errors()); if (! empty($extraFields)) { foreach ($extraFields as $field) { $errors->add($field, 'This field is not allowed.'); diff --git a/app/Http/Controllers/Api/SentinelController.php b/app/Http/Controllers/Api/SentinelController.php index 4a469f09c1..df5c60d406 100644 --- a/app/Http/Controllers/Api/SentinelController.php +++ b/app/Http/Controllers/Api/SentinelController.php @@ -6,8 +6,10 @@ use App\Http\Controllers\Controller; use App\Jobs\PushServerUpdateJob; use App\Models\Server; use Exception; +use Illuminate\Contracts\Cache\LockTimeoutException; use Illuminate\Http\Request; use Illuminate\Support\Facades\Cache; +use Illuminate\Support\Facades\Validator; class SentinelController extends Controller { @@ -77,6 +79,17 @@ class SentinelController extends Controller return response()->json(['message' => 'Unauthorized'], 401); } + $validator = Validator::make($request->all(), [ + 'containers' => ['present', 'array'], + ]); + + if ($validator->fails()) { + return response()->json(serializeApiResponse([ + 'message' => 'Validation failed.', + 'errors' => $validator->errors(), + ]), 422); + } + $data = $request->all(); // Heartbeat MUST update on every push — drives isSentinelLive() and SSH-check skipping. @@ -105,29 +118,38 @@ class SentinelController extends Controller $hash = $this->containerStateHash($data); $hashKey = "sentinel:push-hash:{$server->id}"; $forceKey = "sentinel:push-force:{$server->id}"; + $lockKey = "sentinel:push-lock:{$server->id}"; - $cachedHash = Cache::get($hashKey); - $forceActive = Cache::has($forceKey); + try { + return Cache::lock($lockKey, 10)->block(5, function () use ($hashKey, $forceKey, $hash): bool { + $cachedHash = Cache::get($hashKey); + $forceActive = Cache::has($forceKey); - $shouldDispatch = $cachedHash === null || $cachedHash !== $hash || ! $forceActive; + $shouldDispatch = $cachedHash === null || $cachedHash !== $hash || ! $forceActive; - if ($shouldDispatch) { - // Day-long TTL bounds memory if a server stops pushing entirely. - Cache::put($hashKey, $hash, now()->addDay()); - Cache::put($forceKey, true, config('constants.sentinel.push_force_interval_seconds', 300)); + if ($shouldDispatch) { + // Day-long TTL bounds memory if a server stops pushing entirely. + Cache::put($hashKey, $hash, now()->addDay()); + Cache::put($forceKey, true, config('constants.sentinel.push_force_interval_seconds', 300)); + } + + return $shouldDispatch; + }); + } catch (LockTimeoutException) { + return false; } - - return $shouldDispatch; } /** * Build a stable hash of container state. * - * Covers [name, state, health_status] only — metrics and - * filesystem_usage_root are excluded on purpose (disk % churns constantly - * and would defeat the hash; the storage check is separately cache-gated - * inside PushServerUpdateJob). Sorted by name so container ordering from - * Sentinel does not affect the hash. + * Covers [name, state] only — metrics, filesystem_usage_root, and + * health_status are excluded on purpose. Disk % churns constantly, and + * health checks can flap between starting/healthy/unhealthy while the + * container lifecycle state remains unchanged. Both would otherwise defeat + * the hash and dispatch DB-heavy PushServerUpdateJob instances too often. + * The force window still refreshes full state periodically. Sorted by name + * so container ordering from Sentinel does not affect the hash. */ private function containerStateHash(array $data): string { @@ -135,7 +157,6 @@ class SentinelController extends Controller ->map(fn ($c) => [ 'name' => data_get($c, 'name'), 'state' => data_get($c, 'state'), - 'health_status' => data_get($c, 'health_status'), ]) ->sortBy('name') ->values() diff --git a/app/Http/Controllers/Webhook/Concerns/MatchesManualWebhookApplications.php b/app/Http/Controllers/Webhook/Concerns/MatchesManualWebhookApplications.php index f1fd0c40f6..0463790eb7 100644 --- a/app/Http/Controllers/Webhook/Concerns/MatchesManualWebhookApplications.php +++ b/app/Http/Controllers/Webhook/Concerns/MatchesManualWebhookApplications.php @@ -81,6 +81,10 @@ trait MatchesManualWebhookApplications $path = data_get($parts, 'path'); } elseif (Str::startsWith($gitRepository, 'git@') && str_contains($gitRepository, ':')) { $path = Str::after($gitRepository, ':'); + // scp-style SSH URLs embed a custom port as "git@host:2222/owner/repo". + // Strip the leading numeric port segment so the path matches the webhook + // payload's owner/repo, consistent with convertGitUrl() in shared.php. + $path = preg_replace('#^\d+/#', '', $path) ?? $path; } else { $path = $gitRepository; } diff --git a/app/Http/Controllers/Webhook/Github.php b/app/Http/Controllers/Webhook/Github.php index b481f4a67d..8e3ffcd7c3 100644 --- a/app/Http/Controllers/Webhook/Github.php +++ b/app/Http/Controllers/Webhook/Github.php @@ -62,6 +62,7 @@ class Github extends Controller $before_sha = data_get($payload, 'before'); $after_sha = data_get($payload, 'after', data_get($payload, 'pull_request.head.sha')); $author_association = data_get($payload, 'pull_request.author_association'); + $is_fork_pull_request = $this->isForkPullRequest($payload); } if (! in_array($x_github_event, ['push', 'pull_request'])) { return response("Nothing to do. Event '$x_github_event' is not supported."); @@ -222,6 +223,7 @@ class Github extends Controller commitSha: data_get($payload, 'pull_request.head.sha', 'HEAD'), authorAssociation: $author_association, fullName: $full_name, + isForkPullRequest: $is_fork_pull_request ?? false, ); $return_payloads->push([ @@ -303,6 +305,7 @@ class Github extends Controller $before_sha = data_get($payload, 'before'); $after_sha = data_get($payload, 'after', data_get($payload, 'pull_request.head.sha')); $author_association = data_get($payload, 'pull_request.author_association'); + $is_fork_pull_request = $this->isForkPullRequest($payload); } if (! in_array($x_github_event, ['push', 'pull_request'])) { return response("Nothing to do. Event '$x_github_event' is not supported."); @@ -434,6 +437,7 @@ class Github extends Controller commitSha: data_get($payload, 'pull_request.head.sha', 'HEAD'), authorAssociation: $author_association, fullName: $full_name, + isForkPullRequest: $is_fork_pull_request ?? false, ); $return_payloads->push([ @@ -451,6 +455,40 @@ class Github extends Controller } } + /** + * Determine whether a pull_request webhook payload originates from a fork. + * + * GitHub's `author_association` is not a reliable trust signal (it grants + * CONTRIBUTOR to anyone who has merely opened an issue/PR before), so fork + * detection is gated on whether the PR crosses repository boundaries. + * + * The repository id comparison is the canonical signal; the `head.repo.fork` + * flag and a case-insensitive full_name comparison are fallbacks for payloads + * where the ids are unavailable (e.g. a deleted head repository). + */ + private function isForkPullRequest(mixed $payload): bool + { + $headRepoId = data_get($payload, 'pull_request.head.repo.id'); + $baseRepoId = data_get($payload, 'pull_request.base.repo.id'); + + if ($headRepoId !== null && $baseRepoId !== null) { + return (string) $headRepoId !== (string) $baseRepoId; + } + + if (data_get($payload, 'pull_request.head.repo.fork') === true) { + return true; + } + + $headRepoFullName = data_get($payload, 'pull_request.head.repo.full_name'); + $baseRepoFullName = data_get($payload, 'pull_request.base.repo.full_name'); + + if (is_string($headRepoFullName) && is_string($baseRepoFullName)) { + return Str::lower($headRepoFullName) !== Str::lower($baseRepoFullName); + } + + return false; + } + public function redirect(Request $request) { $code = (string) $request->query('code', ''); diff --git a/app/Jobs/CleanupStaleMultiplexedConnections.php b/app/Jobs/CleanupStaleMultiplexedConnections.php deleted file mode 100644 index 0a10fa4209..0000000000 --- a/app/Jobs/CleanupStaleMultiplexedConnections.php +++ /dev/null @@ -1,295 +0,0 @@ -cleanupStaleConnections(); - $this->cleanupNonExistentServerConnections(); - $this->cleanupDuplicateSshProcesses(); - $this->cleanupOrphanedSshProcesses(); - $this->cleanupOrphanedCloudflaredProcesses(); - } - - /** - * Once two background ssh masters share the same ControlPath, OpenSSH's - * control socket state is no longer trustworthy: `ssh -O check` may report - * one PID while the socket lifecycle is tied to another. Reset the whole - * duplicate group rather than trying to choose an owner. - */ - private function cleanupDuplicateSshProcesses(): void - { - $muxDir = storage_path('app/ssh/mux'); - $groups = []; - - foreach ($this->listProcesses() as $process) { - $controlPath = $this->extractControlPath($process['args']); - if (! is_string($controlPath) || ! str_starts_with($controlPath, $muxDir.'/')) { - continue; - } - - $groups[$controlPath][] = $process; - } - - foreach ($groups as $controlPath => $processes) { - if (count($processes) < 2) { - continue; - } - - $this->resetDuplicateGroup($controlPath, $processes); - } - } - - /** - * Kill backgrounded ssh master processes that lost the ControlPath socket - * race. Such processes are not masters, so ControlPersist never reaps them - * and they leak memory until the container restarts. A legitimate master - * always owns its socket file; an orphan has none. - * - * Processes younger than the minimum age are skipped: a freshly forked - * master creates its socket a few milliseconds after starting, so a young - * process with no socket may simply be mid-establish rather than orphaned. - */ - private function cleanupOrphanedSshProcesses(): void - { - $muxDir = storage_path('app/ssh/mux'); - $minAge = (int) config('constants.ssh.mux_orphan_min_age'); - - foreach ($this->listProcesses() as $process) { - // Only ever touch ssh processes pointing at Coolify's mux directory. - $controlPath = $this->extractControlPath($process['args']); - if (! is_string($controlPath) || ! str_starts_with($controlPath, $muxDir.'/')) { - continue; - } - - if ($process['etimes'] >= $minAge && ! file_exists($controlPath)) { - $this->reapOrphan('ssh', $process); - } - } - } - - /** - * Kill orphaned `cloudflared access ssh` proxy processes. Each is spawned - * as the SSH ProxyCommand transport for a Cloudflare Tunnel server and must - * die with its parent ssh. When that ssh is killed or orphaned (e.g. a lost - * mux master), the cloudflared process can leak and accumulate. A legitimate - * proxy always has a live ssh parent; one without is safe to reap. - * - * Processes younger than the minimum age are skipped so a proxy whose parent - * ssh is still starting up, or a transient `ssh -O check` proxy mid-exit, is - * never mistaken for an orphan. - */ - private function cleanupOrphanedCloudflaredProcesses(): void - { - $minAge = (int) config('constants.ssh.mux_orphan_min_age'); - $processes = $this->listProcesses(); - - $sshPids = []; - foreach ($processes as $process) { - // The ssh binary itself, not `cloudflared access ssh` (space before ssh). - if (preg_match('#(^|/)ssh\s#', $process['args'])) { - $sshPids[$process['pid']] = true; - } - } - - foreach ($processes as $process) { - // `cloudflared access ssh`, never the `cloudflared tunnel` daemon. - if (! str_contains($process['args'], 'cloudflared access ssh')) { - continue; - } - - // Orphaned when no live ssh process is its parent. - if ($process['etimes'] >= $minAge && ! isset($sshPids[$process['ppid']])) { - $this->reapOrphan('cloudflared', $process); - } - } - } - - /** - * Reap a detected orphan process. When orphan reaping is disabled (the - * default), the orphan is only logged — a dry-run mode that lets operators - * verify what would be killed before enabling it for real. - * - * @param array{pid: string, ppid: string, etimes: int, args: string} $process - */ - private function reapOrphan(string $kind, array $process): void - { - if (! config('constants.ssh.mux_orphan_reap_enabled')) { - Log::info("Orphaned {$kind} process detected (dry-run, not killed)", [ - 'pid' => $process['pid'], - 'etimes' => $process['etimes'], - 'command' => $process['args'], - ]); - - return; - } - - Process::run('kill '.escapeshellarg($process['pid'])); - Log::info("Killed orphaned {$kind} process", [ - 'pid' => $process['pid'], - 'etimes' => $process['etimes'], - 'command' => $process['args'], - ]); - } - - /** - * Snapshot of running processes. - * - * @return list - */ - private function listProcesses(): array - { - $ps = Process::run('ps -ww -eo pid=,ppid=,etimes=,args='); - if ($ps->exitCode() !== 0) { - return []; - } - - $processes = []; - foreach (explode("\n", trim($ps->output())) as $line) { - if (! preg_match('/^\s*(\d+)\s+(\d+)\s+(\d+)\s+(.*)$/', $line, $matches)) { - continue; - } - $processes[] = [ - 'pid' => $matches[1], - 'ppid' => $matches[2], - 'etimes' => (int) $matches[3], - 'args' => $matches[4], - ]; - } - - return $processes; - } - - /** - * @param list $processes - */ - private function resetDuplicateGroup(string $controlPath, array $processes): void - { - if (! config('constants.ssh.mux_orphan_reap_enabled')) { - Log::info('Duplicate ssh mux processes detected (dry-run, not killed)', [ - 'control_path' => $controlPath, - 'pids' => array_column($processes, 'pid'), - ]); - - return; - } - - foreach ($processes as $process) { - Process::run('kill '.escapeshellarg($process['pid'])); - } - - if (file_exists($controlPath)) { - @unlink($controlPath); - } - - Log::info('Reset duplicate ssh mux processes', [ - 'control_path' => $controlPath, - 'pids' => array_column($processes, 'pid'), - ]); - } - - private function extractControlPath(string $args): ?string - { - if (! preg_match('/(?:^|\s)-o\s+ControlPath=(?:"([^"]+)"|\'([^\']+)\'|(\S+))/', $args, $matches)) { - if (preg_match('/^ssh:\s+(\S+)\s+\[mux\]$/', $args, $matches)) { - return $matches[1]; - } - - return null; - } - - return $matches[1] ?: ($matches[2] ?: $matches[3]); - } - - private function cleanupStaleConnections() - { - $muxFiles = Storage::disk('ssh-mux')->files(); - - foreach ($muxFiles as $muxFile) { - $serverUuid = $this->extractServerUuidFromMuxFile($muxFile); - $server = Server::where('uuid', $serverUuid)->first(); - - if (! $server) { - $this->removeMultiplexFile($muxFile, 'server_not_found'); - - continue; - } - - $muxSocket = "/var/www/html/storage/app/ssh/mux/{$muxFile}"; - $checkCommand = "ssh -O check -o ControlPath={$muxSocket} {$server->user}@{$server->ip} 2>/dev/null"; - $checkProcess = Process::run($checkCommand); - - if ($checkProcess->exitCode() !== 0) { - $this->removeMultiplexFile($muxFile, 'connection_check_failed'); - } else { - $muxContent = Storage::disk('ssh-mux')->get($muxFile); - $establishedAt = Carbon::parse(substr($muxContent, 37)); - $expirationTime = $establishedAt->addSeconds(config('constants.ssh.mux_persist_time')); - - if (Carbon::now()->isAfter($expirationTime)) { - $this->removeMultiplexFile($muxFile, 'expired'); - } - } - } - } - - private function cleanupNonExistentServerConnections() - { - $muxFiles = Storage::disk('ssh-mux')->files(); - $existingServerUuids = Server::pluck('uuid')->toArray(); - - foreach ($muxFiles as $muxFile) { - $serverUuid = $this->extractServerUuidFromMuxFile($muxFile); - if (! in_array($serverUuid, $existingServerUuids)) { - $this->removeMultiplexFile($muxFile, 'server_does_not_exist'); - } - } - } - - private function extractServerUuidFromMuxFile($muxFile) - { - return substr($muxFile, 4); - } - - /** - * Close and delete a stale mux socket file. When orphan reaping is disabled - * (the default), the file is only logged — a dry-run mode that lets operators - * verify what would be removed before enabling it for real. - */ - private function removeMultiplexFile(string $muxFile, string $reason): void - { - if (! config('constants.ssh.mux_orphan_reap_enabled')) { - Log::info('Stale mux file detected (dry-run, not removed)', [ - 'file' => $muxFile, - 'reason' => $reason, - ]); - - return; - } - - $muxSocket = "/var/www/html/storage/app/ssh/mux/{$muxFile}"; - $closeCommand = "ssh -O exit -o ControlPath={$muxSocket} localhost 2>/dev/null"; - Process::run($closeCommand); - Storage::disk('ssh-mux')->delete($muxFile); - - Log::info('Removed stale mux file', [ - 'file' => $muxFile, - 'reason' => $reason, - ]); - } -} diff --git a/app/Jobs/ProcessGithubPullRequestWebhook.php b/app/Jobs/ProcessGithubPullRequestWebhook.php index 54e386676d..1413517848 100644 --- a/app/Jobs/ProcessGithubPullRequestWebhook.php +++ b/app/Jobs/ProcessGithubPullRequestWebhook.php @@ -39,6 +39,7 @@ class ProcessGithubPullRequestWebhook implements ShouldBeEncrypted, ShouldQueue public string $commitSha, public ?string $authorAssociation, public string $fullName, + public bool $isForkPullRequest = false, ) { $this->onQueue('high'); } @@ -92,7 +93,17 @@ class ProcessGithubPullRequestWebhook implements ShouldBeEncrypted, ShouldQueue // Check if PR deployments from public contributors are restricted if (! $application->settings->is_pr_deployments_public_enabled) { - $trustedAssociations = ['OWNER', 'MEMBER', 'COLLABORATOR', 'CONTRIBUTOR']; + // Fork PRs carry untrusted code from a repository outside our control. + // GitHub's author_association cannot be trusted to gate these (it grants + // CONTRIBUTOR to anyone who has merely opened an issue/PR before), so fork + // PRs are never deployed automatically when public previews are off. + if ($this->isForkPullRequest) { + return; + } + + // Same-repo (non-fork) branch PRs require push access to the base repo, + // so only trusted associations are allowed to trigger a deployment. + $trustedAssociations = ['OWNER', 'MEMBER', 'COLLABORATOR']; if (! in_array($this->authorAssociation, $trustedAssociations)) { return; } diff --git a/app/Jobs/PushServerUpdateJob.php b/app/Jobs/PushServerUpdateJob.php index cdfa174ed0..62e98934ec 100644 --- a/app/Jobs/PushServerUpdateJob.php +++ b/app/Jobs/PushServerUpdateJob.php @@ -13,6 +13,16 @@ use App\Models\ApplicationPreview; use App\Models\Server; use App\Models\ServiceApplication; use App\Models\ServiceDatabase; +use App\Models\StandaloneClickhouse; +use App\Models\StandaloneDocker; +use App\Models\StandaloneDragonfly; +use App\Models\StandaloneKeydb; +use App\Models\StandaloneMariadb; +use App\Models\StandaloneMongodb; +use App\Models\StandaloneMysql; +use App\Models\StandalonePostgresql; +use App\Models\StandaloneRedis; +use App\Models\SwarmDocker; use App\Notifications\Container\ContainerRestarted; use App\Services\ContainerStatusAggregator; use App\Traits\CalculatesExcludedStatus; @@ -25,6 +35,7 @@ use Illuminate\Queue\Middleware\WithoutOverlapping; use Illuminate\Queue\SerializesModels; use Illuminate\Support\Collection; use Illuminate\Support\Facades\Cache; +use Illuminate\Support\Facades\DB; use Laravel\Horizon\Contracts\Silenced; class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced @@ -46,6 +57,18 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced public Collection $services; + public Collection $applicationsById; + + public Collection $previewsByKey; + + public Collection $databasesByUuid; + + public Collection $servicesById; + + public Collection $serviceApplicationsById; + + public Collection $serviceDatabasesById; + public Collection $allApplicationIds; public Collection $allDatabaseUuids; @@ -78,6 +101,8 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced public bool $foundLogDrainContainer = false; + private ?array $cachedDestinationIds = null; + public function middleware(): array { return [(new WithoutOverlapping('push-server-update-'.$this->server->uuid))->expireAfter(30)->dontRelease()]; @@ -103,6 +128,12 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced $this->allTcpProxyUuids = collect(); $this->allServiceApplicationIds = collect(); $this->allServiceDatabaseIds = collect(); + $this->applicationsById = collect(); + $this->previewsByKey = collect(); + $this->databasesByUuid = collect(); + $this->servicesById = collect(); + $this->serviceApplicationsById = collect(); + $this->serviceDatabasesById = collect(); } public function handle() @@ -120,6 +151,16 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced $this->allTcpProxyUuids ??= collect(); $this->allServiceApplicationIds ??= collect(); $this->allServiceDatabaseIds ??= collect(); + $this->applicationsById ??= collect(); + $this->previewsByKey ??= collect(); + $this->databasesByUuid ??= collect(); + $this->servicesById ??= collect(); + $this->serviceApplicationsById ??= collect(); + $this->serviceDatabasesById ??= collect(); + + // Eager-load relations the job touches repeatedly to avoid lazy-load queries + // (settings: disk threshold, isProxyShouldRun, isLogDrainEnabled; team: notifications). + $this->server->loadMissing(['settings', 'team']); // TODO: Swarm is not supported yet if (! $this->data) { @@ -143,19 +184,24 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced && (string) $lastPercentage !== (string) $filesystemUsageRoot) { Cache::put($storageCacheKey, $filesystemUsageRoot, 600); ServerStorageCheckJob::dispatch($this->server, $filesystemUsageRoot); + } elseif ($filesystemUsageRoot !== null && $filesystemUsageRoot < $diskThreshold) { + Cache::forget($storageCacheKey); } if ($this->containers->isEmpty()) { return; } - $this->applications = $this->server->applications(); - $this->databases = $this->server->databases(); - $this->previews = $this->server->previews(); - // Eager load service applications and databases to avoid N+1 queries - $this->services = $this->server->services() - ->with(['applications:id,service_id', 'databases:id,service_id']) - ->get(); + $this->applications = $this->loadApplications(); + $this->databases = $this->loadDatabases(); + $this->previews = $this->loadPreviews(); + $this->services = $this->loadServices(); + $this->applicationsById = $this->applications->keyBy(fn ($application) => (string) $application->id); + $this->previewsByKey = $this->previews->keyBy(fn ($preview) => $preview->application_id.':'.$preview->pull_request_id); + $this->databasesByUuid = $this->databases->keyBy('uuid'); + $this->servicesById = $this->services->keyBy(fn ($service) => (string) $service->id); + $this->serviceApplicationsById = $this->services->flatMap(fn ($service) => $service->applications)->keyBy(fn ($application) => (string) $application->id); + $this->serviceDatabasesById = $this->services->flatMap(fn ($service) => $service->databases)->keyBy(fn ($database) => (string) $database->id); $this->allApplicationIds = $this->applications->filter(function ($application) { return $application->additional_servers_count === 0; @@ -168,9 +214,8 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced }); $this->allDatabaseUuids = $this->databases->pluck('uuid'); $this->allTcpProxyUuids = $this->databases->where('is_public', true)->pluck('uuid'); - // Use eager-loaded relationships instead of querying in loop - $this->allServiceApplicationIds = $this->services->flatMap(fn ($service) => $service->applications->pluck('id')); - $this->allServiceDatabaseIds = $this->services->flatMap(fn ($service) => $service->databases->pluck('id')); + $this->allServiceApplicationIds = $this->serviceApplicationsById->keys(); + $this->allServiceDatabaseIds = $this->serviceDatabasesById->keys(); foreach ($this->containers as $container) { $containerStatus = data_get($container, 'state', 'exited'); @@ -284,6 +329,151 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced $this->checkLogDrainContainer(); } + private function loadApplications(): Collection + { + [$standaloneDockerIds, $swarmDockerIds] = $this->serverDestinationIds(); + + $applications = ($standaloneDockerIds->isNotEmpty() || $swarmDockerIds->isNotEmpty()) + ? Application::withoutGlobalScope('withRelations') + ->select([ + 'id', + 'uuid', + 'name', + 'status', + 'build_pack', + 'docker_compose_raw', + 'destination_id', + 'destination_type', + 'last_online_at', + ]) + ->withCount('additional_servers') + ->where(fn ($query) => $this->scopeDestination($query, $standaloneDockerIds, $swarmDockerIds)) + ->get() + : collect(); + + $additionalApplicationIds = DB::table('additional_destinations') + ->where('server_id', $this->server->id) + ->pluck('application_id'); + + if ($additionalApplicationIds->isNotEmpty()) { + $applications = $applications->concat( + Application::withoutGlobalScope('withRelations') + ->select([ + 'id', + 'uuid', + 'name', + 'status', + 'build_pack', + 'docker_compose_raw', + 'destination_id', + 'destination_type', + 'last_online_at', + ]) + ->withCount('additional_servers') + ->whereIn('id', $additionalApplicationIds) + ->get() + ); + } + + return $applications->unique('id')->values(); + } + + private function loadPreviews(): Collection + { + $applicationIds = $this->applications->pluck('id'); + + if ($applicationIds->isEmpty()) { + return collect(); + } + + return ApplicationPreview::query() + ->select([ + 'id', + 'application_id', + 'pull_request_id', + 'status', + 'last_online_at', + ]) + ->whereIn('application_id', $applicationIds) + ->get(); + } + + private function loadServices(): Collection + { + return $this->server->services() + ->select([ + 'id', + 'server_id', + 'uuid', + 'docker_compose_raw', + ]) + ->with([ + 'applications:id,service_id,status,last_online_at', + 'databases:id,service_id,status,last_online_at,is_public,name', + ]) + ->get(); + } + + private function loadDatabases(): Collection + { + [$standaloneDockerIds, $swarmDockerIds] = $this->serverDestinationIds(); + if ($standaloneDockerIds->isEmpty() && $swarmDockerIds->isEmpty()) { + return collect(); + } + $databaseColumns = [ + 'id', + 'uuid', + 'name', + 'status', + 'is_public', + 'destination_id', + 'destination_type', + 'last_online_at', + 'restart_count', + 'last_restart_at', + 'last_restart_type', + ]; + + return collect([ + StandalonePostgresql::class, + StandaloneRedis::class, + StandaloneMongodb::class, + StandaloneMysql::class, + StandaloneMariadb::class, + StandaloneKeydb::class, + StandaloneDragonfly::class, + StandaloneClickhouse::class, + ])->flatMap(function (string $databaseClass) use ($databaseColumns, $standaloneDockerIds, $swarmDockerIds) { + return $databaseClass::query() + ->select($databaseColumns) + ->where(fn ($query) => $this->scopeDestination($query, $standaloneDockerIds, $swarmDockerIds)) + ->get(); + })->filter(fn ($database) => data_get($database, 'name') !== 'coolify-db')->values(); + } + + private function serverDestinationIds(): array + { + if ($this->cachedDestinationIds !== null) { + return $this->cachedDestinationIds; + } + + return $this->cachedDestinationIds = [ + StandaloneDocker::where('server_id', $this->server->id)->pluck('id'), + SwarmDocker::where('server_id', $this->server->id)->pluck('id'), + ]; + } + + private function scopeDestination($query, Collection $standaloneDockerIds, Collection $swarmDockerIds): void + { + $query->where(function ($query) use ($standaloneDockerIds) { + $query->where('destination_type', StandaloneDocker::class) + ->whereIn('destination_id', $standaloneDockerIds); + })->orWhere(function ($query) use ($swarmDockerIds) { + $query->where('destination_type', SwarmDocker::class) + ->whereIn('destination_id', $swarmDockerIds); + }); + } + private function aggregateMultiContainerStatuses() { if ($this->applicationContainerStatuses->isEmpty()) { @@ -291,7 +481,7 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced } foreach ($this->applicationContainerStatuses as $applicationId => $containerStatuses) { - $application = $this->applications->where('id', $applicationId)->first(); + $application = $this->applicationsById->get((string) $applicationId); if (! $application) { continue; } @@ -312,8 +502,6 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced if ($aggregatedStatus && $application->status !== $aggregatedStatus) { $application->status = $aggregatedStatus; $application->save(); - } elseif ($aggregatedStatus) { - $application->update(['last_online_at' => now()]); } continue; @@ -328,8 +516,6 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced if ($aggregatedStatus && $application->status !== $aggregatedStatus) { $application->status = $aggregatedStatus; $application->save(); - } elseif ($aggregatedStatus) { - $application->update(['last_online_at' => now()]); } } } @@ -348,7 +534,7 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced continue; } - $service = $this->services->where('id', $serviceId)->first(); + $service = $this->servicesById->get((string) $serviceId); if (! $service) { continue; } @@ -356,9 +542,9 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced // Get the service sub-resource (ServiceApplication or ServiceDatabase) $subResource = null; if ($subType === 'application') { - $subResource = $service->applications->where('id', $subId)->first(); + $subResource = $this->serviceApplicationsById->get((string) $subId); } elseif ($subType === 'database') { - $subResource = $service->databases->where('id', $subId)->first(); + $subResource = $this->serviceDatabasesById->get((string) $subId); } if (! $subResource) { @@ -380,8 +566,6 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced if ($aggregatedStatus && $subResource->status !== $aggregatedStatus) { $subResource->status = $aggregatedStatus; $subResource->save(); - } elseif ($aggregatedStatus) { - $subResource->update(['last_online_at' => now()]); } continue; @@ -397,39 +581,31 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced if ($aggregatedStatus && $subResource->status !== $aggregatedStatus) { $subResource->status = $aggregatedStatus; $subResource->save(); - } elseif ($aggregatedStatus) { - $subResource->update(['last_online_at' => now()]); } } } private function updateApplicationStatus(string $applicationId, string $containerStatus) { - $application = $this->applications->where('id', $applicationId)->first(); + $application = $this->applicationsById->get((string) $applicationId); if (! $application) { return; } if ($application->status !== $containerStatus) { $application->status = $containerStatus; $application->save(); - } else { - $application->update(['last_online_at' => now()]); } } private function updateApplicationPreviewStatus(string $applicationId, string $pullRequestId, string $containerStatus) { - $application = $this->previews->where('application_id', $applicationId) - ->where('pull_request_id', $pullRequestId) - ->first(); + $application = $this->previewsByKey->get($applicationId.':'.$pullRequestId); if (! $application) { return; } if ($application->status !== $containerStatus) { $application->status = $containerStatus; $application->save(); - } else { - $application->update(['last_online_at' => now()]); } } @@ -477,9 +653,7 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced $applicationId = $parts[0]; $pullRequestId = $parts[1]; - $applicationPreview = $this->previews->where('application_id', $applicationId) - ->where('pull_request_id', $pullRequestId) - ->first(); + $applicationPreview = $this->previewsByKey->get($applicationId.':'.$pullRequestId); if ($applicationPreview && ! str($applicationPreview->status)->startsWith('exited')) { $previewIdsToUpdate->push($applicationPreview->id); @@ -518,15 +692,13 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced private function updateDatabaseStatus(string $databaseUuid, string $containerStatus, bool $tcpProxy = false) { - $database = $this->databases->where('uuid', $databaseUuid)->first(); + $database = $this->databasesByUuid->get($databaseUuid); if (! $database) { return; } if ($database->status !== $containerStatus) { $database->status = $containerStatus; $database->save(); - } else { - $database->update(['last_online_at' => now()]); } if ($this->isRunning($containerStatus) && $tcpProxy) { $tcpProxyContainerFound = $this->containers->filter(function ($value, $key) use ($databaseUuid) { @@ -561,7 +733,7 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced } $notFoundDatabaseUuids->each(function ($databaseUuid) { - $database = $this->databases->where('uuid', $databaseUuid)->first(); + $database = $this->databasesByUuid->get($databaseUuid); if ($database) { if (! str($database->status)->startsWith('exited')) { $database->update([ diff --git a/app/Jobs/ScheduledJobManager.php b/app/Jobs/ScheduledJobManager.php index bd8ee2819a..e7a21949cf 100644 --- a/app/Jobs/ScheduledJobManager.php +++ b/app/Jobs/ScheduledJobManager.php @@ -6,14 +6,15 @@ use App\Models\ScheduledDatabaseBackup; use App\Models\ScheduledTask; use App\Models\Server; use App\Models\Team; +use Cron\CronExpression; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\Middleware\WithoutOverlapping; use Illuminate\Queue\SerializesModels; use Illuminate\Support\Carbon; -use Illuminate\Support\Collection; use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Redis; @@ -22,6 +23,8 @@ class ScheduledJobManager implements ShouldQueue { use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; + private const CHUNK_SIZE = 100; + /** * The time when this job execution started. * Used to ensure all scheduled items are evaluated against the same point in time. @@ -96,21 +99,11 @@ class ScheduledJobManager implements ShouldQueue 'execution_time' => $this->executionTime->toIso8601String(), ]); - // Process backups - don't let failures stop task processing + // Process scheduled backups and tasks together so neither type starves the other. try { - $this->processScheduledBackups(); + $this->processScheduledBackupsAndTasks(); } catch (\Exception $e) { - Log::channel('scheduled-errors')->error('Failed to process scheduled backups', [ - 'error' => $e->getMessage(), - 'trace' => $e->getTraceAsString(), - ]); - } - - // Process tasks - don't let failures stop the job manager - try { - $this->processScheduledTasks(); - } catch (\Exception $e) { - Log::channel('scheduled-errors')->error('Failed to process scheduled tasks', [ + Log::channel('scheduled-errors')->error('Failed to process scheduled backups and tasks', [ 'error' => $e->getMessage(), 'trace' => $e->getTraceAsString(), ]); @@ -141,125 +134,211 @@ class ScheduledJobManager implements ShouldQueue } } - private function processScheduledBackups(): void + private function processScheduledBackupsAndTasks(): void { - $backups = ScheduledDatabaseBackup::with(['database']) + $lastBackupId = 0; + $lastTaskId = 0; + + do { + $backups = $this->scheduledBackupQuery($lastBackupId)->get(); + $tasks = $this->scheduledTaskQuery($lastTaskId)->get(); + + if ($backups->isNotEmpty()) { + $lastBackupId = $backups->last()->id; + } + + if ($tasks->isNotEmpty()) { + $lastTaskId = $tasks->last()->id; + } + + $this->processInterleavedDueSchedules( + $this->dueScheduledBackups($backups), + $this->dueScheduledTasks($tasks), + ); + } while ($backups->isNotEmpty() || $tasks->isNotEmpty()); + } + + /** + * @param array $dueBackups + * @param array $dueTasks + */ + private function processInterleavedDueSchedules(array $dueBackups, array $dueTasks): void + { + $maxCount = max(count($dueBackups), count($dueTasks)); + + for ($index = 0; $index < $maxCount; $index++) { + if (isset($dueBackups[$index])) { + $this->processScheduledBackup($dueBackups[$index]['backup'], $dueBackups[$index]['server']); + } + + if (isset($dueTasks[$index])) { + $this->processScheduledTask($dueTasks[$index]['task'], $dueTasks[$index]['server']); + } + } + } + + private function scheduledBackupQuery(int $lastBackupId): Builder + { + return ScheduledDatabaseBackup::with(['database', 'team.subscription']) ->where('enabled', true) - ->get(); + ->where('id', '>', $lastBackupId) + ->orderBy('id') + ->limit(self::CHUNK_SIZE); + } + + private function scheduledTaskQuery(int $lastTaskId): Builder + { + return ScheduledTask::with([ + 'service.destination.server.settings', + 'service.destination.server.team.subscription', + 'application.destination.server.settings', + 'application.destination.server.team.subscription', + ]) + ->where('enabled', true) + ->where('id', '>', $lastTaskId) + ->orderBy('id') + ->limit(self::CHUNK_SIZE); + } + + /** + * @param iterable $backups + * @return array + */ + private function dueScheduledBackups(iterable $backups): array + { + $dueBackups = []; foreach ($backups as $backup) { try { $server = $backup->server(); - $skipReason = $this->getBackupSkipReason($backup, $server); - if ($skipReason !== null) { - $this->skippedCount++; - $this->logSkip('backup', $skipReason, [ - 'backup_id' => $backup->id, - 'database_id' => $backup->database_id, - 'database_type' => $backup->database_type, - 'team_id' => $backup->team_id ?? null, - ]); + + if (blank(data_get($backup, 'database')) || blank($server)) { + $this->processScheduledBackup($backup, $server); continue; } - $serverTimezone = data_get($server->settings, 'server_timezone', config('app.timezone')); - - if (validate_timezone($serverTimezone) === false) { - $serverTimezone = config('app.timezone'); - } - - $frequency = $backup->frequency; - if (isset(VALID_CRON_STRINGS[$frequency])) { - $frequency = VALID_CRON_STRINGS[$frequency]; - } - - if (shouldRunCronNow($frequency, $serverTimezone, "scheduled-backup:{$backup->id}", $this->executionTime)) { - DatabaseBackupJob::dispatch($backup); - $this->dispatchedCount++; - Log::channel('scheduled')->info('Backup dispatched', [ - 'backup_id' => $backup->id, - 'database_id' => $backup->database_id, - 'database_type' => $backup->database_type, - 'team_id' => $backup->team_id ?? null, - 'server_id' => $server->id, - ]); + if ($this->isDueCandidateBeforeExpensiveChecks($backup->frequency, $server, "scheduled-backup:{$backup->id}")) { + $dueBackups[] = [ + 'backup' => $backup, + 'server' => $server, + ]; } } catch (\Exception $e) { - Log::channel('scheduled-errors')->error('Error processing backup', [ + Log::channel('scheduled-errors')->error('Error prechecking backup', [ 'backup_id' => $backup->id, 'error' => $e->getMessage(), ]); } } + + return $dueBackups; } - private function processScheduledTasks(): void + /** + * @param iterable $tasks + * @return array + */ + private function dueScheduledTasks(iterable $tasks): array { - $tasks = ScheduledTask::with(['service', 'application']) - ->where('enabled', true) - ->get(); + $dueTasks = []; foreach ($tasks as $task) { try { $server = $task->server(); - // Phase 1: Critical checks (always — cheap, handles orphans and infra issues) - $criticalSkip = $this->getTaskCriticalSkipReason($task, $server); - if ($criticalSkip !== null) { - $this->skippedCount++; - $this->logSkip('task', $criticalSkip, [ - 'task_id' => $task->id, - 'task_name' => $task->name, - 'team_id' => $server?->team_id, - ]); + if (blank($server) || (! $task->service && ! $task->application)) { + $this->processScheduledTask($task, $server); continue; } - $serverTimezone = data_get($server->settings, 'server_timezone', config('app.timezone')); - - if (validate_timezone($serverTimezone) === false) { - $serverTimezone = config('app.timezone'); + if ($this->isDueCandidateBeforeExpensiveChecks($task->frequency, $server, "scheduled-task:{$task->id}")) { + $dueTasks[] = [ + 'task' => $task, + 'server' => $server, + ]; } - - $frequency = $task->frequency; - if (isset(VALID_CRON_STRINGS[$frequency])) { - $frequency = VALID_CRON_STRINGS[$frequency]; - } - - if (! shouldRunCronNow($frequency, $serverTimezone, "scheduled-task:{$task->id}", $this->executionTime)) { - continue; - } - - // Phase 2: Runtime checks (only when cron is due — avoids noise for stopped resources) - $runtimeSkip = $this->getTaskRuntimeSkipReason($task); - if ($runtimeSkip !== null) { - $this->skippedCount++; - $this->logSkip('task', $runtimeSkip, [ - 'task_id' => $task->id, - 'task_name' => $task->name, - 'team_id' => $server->team_id, - ]); - - continue; - } - - ScheduledTaskJob::dispatch($task); - $this->dispatchedCount++; - Log::channel('scheduled')->info('Task dispatched', [ - 'task_id' => $task->id, - 'task_name' => $task->name, - 'team_id' => $server->team_id, - 'server_id' => $server->id, - ]); } catch (\Exception $e) { - Log::channel('scheduled-errors')->error('Error processing task', [ + Log::channel('scheduled-errors')->error('Error prechecking task', [ 'task_id' => $task->id, 'error' => $e->getMessage(), ]); } } + + return $dueTasks; + } + + private function processScheduledBackup(ScheduledDatabaseBackup $backup, ?Server $precheckedServer = null): void + { + try { + $server = $precheckedServer ?? $backup->server(); + $skipReason = $this->getBackupSkipReason($backup, $server); + if ($skipReason !== null) { + $this->skippedCount++; + $this->logBackupSkip($backup, $skipReason); + + return; + } + + if ($this->shouldDispatch($backup->frequency, $server, "scheduled-backup:{$backup->id}")) { + DatabaseBackupJob::dispatch($backup); + $this->dispatchedCount++; + Log::channel('scheduled')->info('Backup dispatched', [ + 'backup_id' => $backup->id, + 'database_id' => $backup->database_id, + 'database_type' => $backup->database_type, + 'team_id' => $backup->team_id ?? null, + 'server_id' => $server->id, + ]); + } + } catch (\Exception $e) { + Log::channel('scheduled-errors')->error('Error processing backup', [ + 'backup_id' => $backup->id, + 'error' => $e->getMessage(), + ]); + } + } + + private function processScheduledTask(ScheduledTask $task, ?Server $precheckedServer = null): void + { + try { + $server = $precheckedServer ?? $task->server(); + $criticalSkip = $this->getTaskCriticalSkipReason($task, $server); + if ($criticalSkip !== null) { + $this->skippedCount++; + $this->logTaskSkip($task, $criticalSkip, $server); + + return; + } + + if (! $this->shouldDispatch($task->frequency, $server, "scheduled-task:{$task->id}")) { + return; + } + + $runtimeSkip = $this->getTaskRuntimeSkipReason($task); + if ($runtimeSkip !== null) { + $this->skippedCount++; + $this->logTaskSkip($task, $runtimeSkip, $server); + + return; + } + + ScheduledTaskJob::dispatch($task); + $this->dispatchedCount++; + Log::channel('scheduled')->info('Task dispatched', [ + 'task_id' => $task->id, + 'task_name' => $task->name, + 'team_id' => $server->team_id, + 'server_id' => $server->id, + ]); + } catch (\Exception $e) { + Log::channel('scheduled-errors')->error('Error processing task', [ + 'task_id' => $task->id, + 'error' => $e->getMessage(), + ]); + } } private function getBackupSkipReason(ScheduledDatabaseBackup $backup, ?Server $server): ?string @@ -327,71 +406,70 @@ class ScheduledJobManager implements ShouldQueue private function processDockerCleanups(): void { - // Get all servers that need cleanup checks - $servers = $this->getServersForCleanup(); - - foreach ($servers as $server) { - try { - $skipReason = $this->getDockerCleanupSkipReason($server); - if ($skipReason !== null) { - $this->skippedCount++; - $this->logSkip('docker_cleanup', $skipReason, [ - 'server_id' => $server->id, - 'server_name' => $server->name, - 'team_id' => $server->team_id, - ]); - - continue; + $this->getServersForCleanupQuery() + ->chunkById(self::CHUNK_SIZE, function ($servers): void { + foreach ($servers as $server) { + $this->processDockerCleanup($server); } + }); + } - $serverTimezone = data_get($server->settings, 'server_timezone', config('app.timezone')); - if (validate_timezone($serverTimezone) === false) { - $serverTimezone = config('app.timezone'); - } - - $frequency = data_get($server->settings, 'docker_cleanup_frequency', '0 * * * *'); - if (isset(VALID_CRON_STRINGS[$frequency])) { - $frequency = VALID_CRON_STRINGS[$frequency]; - } - - // Use the frozen execution time for consistent evaluation - if (shouldRunCronNow($frequency, $serverTimezone, "docker-cleanup:{$server->id}", $this->executionTime)) { - DockerCleanupJob::dispatch( - $server, - false, - $server->settings->delete_unused_volumes, - $server->settings->delete_unused_networks - ); - $this->dispatchedCount++; - Log::channel('scheduled')->info('Docker cleanup dispatched', [ - 'server_id' => $server->id, - 'server_name' => $server->name, - 'team_id' => $server->team_id, - ]); - } - } catch (\Exception $e) { - Log::channel('scheduled-errors')->error('Error processing docker cleanup', [ + private function processDockerCleanup(Server $server): void + { + try { + $skipReason = $this->getDockerCleanupSkipReason($server); + if ($skipReason !== null) { + $this->skippedCount++; + $this->logSkip('docker_cleanup', $skipReason, [ 'server_id' => $server->id, 'server_name' => $server->name, - 'error' => $e->getMessage(), + 'team_id' => $server->team_id, + ]); + + return; + } + + $frequency = data_get($server->settings, 'docker_cleanup_frequency', '0 * * * *'); + + if ($this->shouldDispatch($frequency, $server, "docker-cleanup:{$server->id}")) { + DockerCleanupJob::dispatch( + $server, + false, + $server->settings->delete_unused_volumes, + $server->settings->delete_unused_networks + ); + $this->dispatchedCount++; + Log::channel('scheduled')->info('Docker cleanup dispatched', [ + 'server_id' => $server->id, + 'server_name' => $server->name, + 'team_id' => $server->team_id, ]); } + } catch (\Exception $e) { + Log::channel('scheduled-errors')->error('Error processing docker cleanup', [ + 'server_id' => $server->id, + 'server_name' => $server->name, + 'error' => $e->getMessage(), + ]); } } - private function getServersForCleanup(): Collection + private function getServersForCleanupQuery(): Builder { $query = Server::with('settings') ->where('ip', '!=', '1.2.3.4'); if (isCloud()) { - $servers = $query->whereRelation('team.subscription', 'stripe_invoice_paid', true)->get(); - $own = Team::find(0)->servers()->with('settings')->get(); - - return $servers->merge($own); + $query + ->with('team.subscription') + ->where(function (Builder $query): void { + $query + ->where('team_id', 0) + ->orWhereRelation('team.subscription', 'stripe_invoice_paid', true); + }); } - return $query->get(); + return $query; } private function getDockerCleanupSkipReason(Server $server): ?string @@ -418,4 +496,71 @@ class ScheduledJobManager implements ShouldQueue 'execution_time' => $this->executionTime?->toIso8601String(), ], $context)); } + + private function shouldDispatch(string $frequency, Server $server, string $dedupKey): bool + { + return shouldRunCronNow( + $this->normalizeFrequency($frequency), + $this->serverTimezone($server), + $dedupKey, + $this->executionTime, + ); + } + + private function isDueCandidateBeforeExpensiveChecks(string $frequency, Server $server, string $dedupKey): bool + { + $cron = new CronExpression($this->normalizeFrequency($frequency)); + $executionTime = ($this->executionTime ?? Carbon::now())->copy()->setTimezone($this->serverTimezone($server)); + $lastDispatched = Cache::get($dedupKey); + $previousDue = Carbon::instance($cron->getPreviousRunDate($executionTime, allowCurrentDate: true)); + + if ($lastDispatched === null) { + $isDue = $cron->isDue($executionTime); + + if (! $isDue) { + Cache::put($dedupKey, $previousDue->toIso8601String(), 2592000); + } + + return $isDue; + } + + $shouldFire = $previousDue->gt(Carbon::parse($lastDispatched)); + + if (! $shouldFire) { + Cache::put($dedupKey, $previousDue->toIso8601String(), 2592000); + } + + return $shouldFire; + } + + private function normalizeFrequency(string $frequency): string + { + return VALID_CRON_STRINGS[$frequency] ?? $frequency; + } + + private function serverTimezone(Server $server): string + { + $timezone = data_get($server->settings, 'server_timezone', config('app.timezone')); + + return validate_timezone($timezone) ? $timezone : config('app.timezone'); + } + + private function logBackupSkip(ScheduledDatabaseBackup $backup, string $reason): void + { + $this->logSkip('backup', $reason, [ + 'backup_id' => $backup->id, + 'database_id' => $backup->database_id, + 'database_type' => $backup->database_type, + 'team_id' => $backup->team_id ?? null, + ]); + } + + private function logTaskSkip(ScheduledTask $task, string $reason, ?Server $server): void + { + $this->logSkip('task', $reason, [ + 'task_id' => $task->id, + 'task_name' => $task->name, + 'team_id' => $server?->team_id, + ]); + } } diff --git a/app/Jobs/ScheduledTaskJob.php b/app/Jobs/ScheduledTaskJob.php index 67ef1ebe32..dc11ec89e7 100644 --- a/app/Jobs/ScheduledTaskJob.php +++ b/app/Jobs/ScheduledTaskJob.php @@ -40,13 +40,13 @@ class ScheduledTaskJob implements ShouldBeEncrypted, ShouldQueue */ public $timeout = 300; - public Team $team; + public ?Team $team = null; public ?Server $server = null; public ScheduledTask $task; - public Application|Service $resource; + public Application|Service|null $resource = null; public ?ScheduledTaskExecution $task_log = null; @@ -61,25 +61,34 @@ class ScheduledTaskJob implements ShouldBeEncrypted, ShouldQueue public array $containers = []; - public string $server_timezone; + public string $server_timezone = 'UTC'; - public function __construct($task) + public function __construct(ScheduledTask $task) { $this->onQueue(crons_queue()); $this->task = $task; - if ($service = $task->service()->first()) { - $this->resource = $service; - } elseif ($application = $task->application()->first()) { - $this->resource = $application; + $this->timeout = $this->task->timeout ?? 300; + } + + private function initializeExecutionContext(): void + { + $this->task->loadMissing([ + 'service.destination.server.settings', + 'application.destination.server.settings', + ]); + + if ($this->task->service) { + $this->resource = $this->task->service; + } elseif ($this->task->application) { + $this->resource = $this->task->application; } else { throw new \RuntimeException('ScheduledTaskJob failed: No resource found.'); } - $this->team = Team::findOrFail($task->team_id); - $this->server_timezone = $this->getServerTimezone(); - // Set timeout from task configuration - $this->timeout = $this->task->timeout ?? 300; + $this->team = Team::findOrFail($this->task->team_id); + $this->server_timezone = $this->getServerTimezone(); + $this->server = $this->resource->destination->server; } private function getServerTimezone(): string @@ -98,6 +107,8 @@ class ScheduledTaskJob implements ShouldBeEncrypted, ShouldQueue $startTime = Carbon::now(); try { + $this->initializeExecutionContext(); + $this->task_log = ScheduledTaskExecution::create([ 'scheduled_task_id' => $this->task->id, 'started_at' => $startTime, @@ -107,8 +118,6 @@ class ScheduledTaskJob implements ShouldBeEncrypted, ShouldQueue // Store execution ID for timeout handling $this->executionId = $this->task_log->id; - $this->server = $this->resource->destination->server; - if ($this->resource->type() === 'application') { $containers = getCurrentApplicationContainerStatus($this->server, $this->resource->id, 0); if ($containers->count() > 0) { @@ -179,7 +188,10 @@ class ScheduledTaskJob implements ShouldBeEncrypted, ShouldQueue // Re-throw to trigger Laravel's retry mechanism with backoff throw $e; } finally { - ScheduledTaskDone::dispatch($this->team->id); + if ($this->team) { + ScheduledTaskDone::dispatch($this->team->id); + } + if ($this->task_log) { $finishedAt = Carbon::now(); $duration = round($startTime->floatDiffInSeconds($finishedAt), 2); @@ -205,6 +217,8 @@ class ScheduledTaskJob implements ShouldBeEncrypted, ShouldQueue */ public function failed(?\Throwable $exception): void { + $this->team ??= Team::find($this->task->team_id); + Log::channel('scheduled-errors')->error('ScheduledTask permanently failed', [ 'job' => 'ScheduledTaskJob', 'task_id' => $this->task->uuid, diff --git a/app/Livewire/Profile/Appearance.php b/app/Livewire/Profile/Appearance.php new file mode 100644 index 0000000000..6a1b72f80f --- /dev/null +++ b/app/Livewire/Profile/Appearance.php @@ -0,0 +1,13 @@ +user()->currentTeam()->id; - - return [ - "echo-private:team.{$teamId},ServiceChecked" => '$refresh', - "echo-private:team.{$teamId},ServiceStatusChanged" => '$refresh', - 'buildPackUpdated' => '$refresh', - 'refresh' => '$refresh', - ]; - } + protected $listeners = [ + 'buildPackUpdated' => '$refresh', + 'refresh' => '$refresh', + ]; public function mount() { @@ -51,8 +44,6 @@ class Configuration extends Component $this->environment = $environment; $this->application = $application; - - if ($this->application->build_pack === 'dockercompose' && $this->currentRoute === 'project.application.healthcheck') { return redirect()->route('project.application.configuration', ['project_uuid' => $project->uuid, 'environment_uuid' => $environment->uuid, 'application_uuid' => $application->uuid]); } diff --git a/app/Livewire/Project/Application/ServerStatusBadge.php b/app/Livewire/Project/Application/ServerStatusBadge.php new file mode 100644 index 0000000000..459271e288 --- /dev/null +++ b/app/Livewire/Project/Application/ServerStatusBadge.php @@ -0,0 +1,41 @@ +currentTeam(); + if (! $team) { + return []; + } + + return [ + "echo-private:team.{$team->id},ServiceStatusChanged" => 'refreshStatus', + "echo-private:team.{$team->id},ServiceChecked" => 'refreshStatus', + ]; + } + + public function refreshStatus(): void + { + $this->application->refresh(); + } + + public function render(): View + { + return view('livewire.project.application.server-status-badge'); + } +} diff --git a/app/Livewire/Project/Database/Clickhouse/General.php b/app/Livewire/Project/Database/Clickhouse/General.php index 2583c10ea9..6946743266 100644 --- a/app/Livewire/Project/Database/Clickhouse/General.php +++ b/app/Livewire/Project/Database/Clickhouse/General.php @@ -40,18 +40,21 @@ class General extends Component public ?string $customDockerRunOptions = null; - public ?string $dbUrl = null; - - public ?string $dbUrlPublic = null; - public bool $isLogDrainEnabled = false; - public function getListeners() + public function getListeners(): array { - $teamId = Auth::user()->currentTeam()->id; + $user = Auth::user(); + if (! $user) { + return []; + } + $team = $user->currentTeam(); + if (! $team) { + return []; + } return [ - "echo-private:team.{$teamId},DatabaseProxyStopped" => 'databaseProxyStopped', + "echo-private:team.{$team->id},DatabaseProxyStopped" => 'databaseProxyStopped', ]; } @@ -88,8 +91,6 @@ class General extends Component 'publicPort' => 'nullable|integer|min:1|max:65535', 'publicPortTimeout' => 'nullable|integer|min:1', 'customDockerRunOptions' => 'nullable|string', - 'dbUrl' => 'nullable|string', - 'dbUrlPublic' => 'nullable|string', 'isLogDrainEnabled' => 'nullable|boolean', ]; } @@ -129,9 +130,6 @@ class General extends Component $this->database->custom_docker_run_options = $this->customDockerRunOptions; $this->database->is_log_drain_enabled = $this->isLogDrainEnabled; $this->database->save(); - - $this->dbUrl = $this->database->internal_db_url; - $this->dbUrlPublic = $this->database->external_db_url; } else { $this->name = $this->database->name; $this->description = $this->database->description; @@ -144,8 +142,6 @@ class General extends Component $this->publicPortTimeout = $this->database->public_port_timeout; $this->customDockerRunOptions = $this->database->custom_docker_run_options; $this->isLogDrainEnabled = $this->database->is_log_drain_enabled; - $this->dbUrl = $this->database->internal_db_url; - $this->dbUrlPublic = $this->database->external_db_url; } } @@ -194,6 +190,7 @@ class General extends Component StopDatabaseProxy::run($this->database); $this->dispatch('success', 'Database is no longer publicly accessible.'); } + $this->dispatch('databaseUpdated'); } catch (\Throwable $e) { $this->isPublic = ! $this->isPublic; $this->syncData(true); @@ -202,9 +199,13 @@ class General extends Component } } - public function databaseProxyStopped() + public function databaseProxyStopped(): void { - $this->syncData(); + $this->database->refresh(); + $this->isPublic = $this->database->is_public; + $this->publicPort = $this->database->public_port; + $this->publicPortTimeout = $this->database->public_port_timeout; + $this->dispatch('databaseUpdated'); } public function submit() @@ -220,6 +221,7 @@ class General extends Component } $this->syncData(true); $this->dispatch('success', 'Database updated.'); + $this->dispatch('databaseUpdated'); } catch (Exception $e) { return handleError($e, $this); } finally { diff --git a/app/Livewire/Project/Database/Clickhouse/StatusInfo.php b/app/Livewire/Project/Database/Clickhouse/StatusInfo.php new file mode 100644 index 0000000000..51a3192fae --- /dev/null +++ b/app/Livewire/Project/Database/Clickhouse/StatusInfo.php @@ -0,0 +1,31 @@ +currentTeam()->id; - - return [ - "echo-private:team.{$teamId},ServiceChecked" => '$refresh', - ]; - } - public function mount() { try { @@ -55,10 +47,10 @@ class Configuration extends Component $this->dispatch('configurationChanged'); } } catch (\Throwable $e) { - if ($e instanceof \Illuminate\Auth\Access\AuthorizationException) { + if ($e instanceof AuthorizationException) { return redirect()->route('dashboard'); } - if ($e instanceof \Illuminate\Support\ItemNotFoundException) { + if ($e instanceof ItemNotFoundException) { return redirect()->route('dashboard'); } diff --git a/app/Livewire/Project/Database/Dragonfly/General.php b/app/Livewire/Project/Database/Dragonfly/General.php index 9e1ea0d105..f196b9dfb1 100644 --- a/app/Livewire/Project/Database/Dragonfly/General.php +++ b/app/Livewire/Project/Database/Dragonfly/General.php @@ -4,11 +4,9 @@ namespace App\Livewire\Project\Database\Dragonfly; use App\Actions\Database\StartDatabaseProxy; use App\Actions\Database\StopDatabaseProxy; -use App\Helpers\SslHelper; use App\Models\Server; use App\Models\StandaloneDragonfly; use App\Support\ValidationPatterns; -use Carbon\Carbon; use Exception; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Support\Facades\Auth; @@ -40,25 +38,21 @@ class General extends Component public ?string $customDockerRunOptions = null; - public ?string $dbUrl = null; - - public ?string $dbUrlPublic = null; - public bool $isLogDrainEnabled = false; - public ?Carbon $certificateValidUntil = null; - - public bool $enable_ssl = false; - - public function getListeners() + public function getListeners(): array { - $userId = Auth::id(); - $teamId = Auth::user()->currentTeam()->id; + $user = Auth::user(); + if (! $user) { + return []; + } + $team = $user->currentTeam(); + if (! $team) { + return []; + } return [ - "echo-private:team.{$teamId},DatabaseProxyStopped" => 'databaseProxyStopped', - "echo-private:user.{$userId},DatabaseStatusChanged" => 'refresh', - "echo-private:team.{$teamId},ServiceChecked" => 'refresh', + "echo-private:team.{$team->id},DatabaseProxyStopped" => 'databaseProxyStopped', ]; } @@ -73,12 +67,6 @@ class General extends Component return; } - - $existingCert = $this->database->sslCertificates()->first(); - - if ($existingCert) { - $this->certificateValidUntil = $existingCert->valid_until; - } } catch (\Throwable $e) { return handleError($e, $this); } @@ -98,10 +86,7 @@ class General extends Component 'publicPort' => 'nullable|integer|min:1|max:65535', 'publicPortTimeout' => 'nullable|integer|min:1', 'customDockerRunOptions' => 'nullable|string', - 'dbUrl' => 'nullable|string', - 'dbUrlPublic' => 'nullable|string', 'isLogDrainEnabled' => 'nullable|boolean', - 'enable_ssl' => 'nullable|boolean', ]; } @@ -137,11 +122,7 @@ class General extends Component $this->database->public_port_timeout = $this->publicPortTimeout ?: null; $this->database->custom_docker_run_options = $this->customDockerRunOptions; $this->database->is_log_drain_enabled = $this->isLogDrainEnabled; - $this->database->enable_ssl = $this->enable_ssl; $this->database->save(); - - $this->dbUrl = $this->database->internal_db_url; - $this->dbUrlPublic = $this->database->external_db_url; } else { $this->name = $this->database->name; $this->description = $this->database->description; @@ -153,9 +134,6 @@ class General extends Component $this->publicPortTimeout = $this->database->public_port_timeout; $this->customDockerRunOptions = $this->database->custom_docker_run_options; $this->isLogDrainEnabled = $this->database->is_log_drain_enabled; - $this->enable_ssl = $this->database->enable_ssl; - $this->dbUrl = $this->database->internal_db_url; - $this->dbUrlPublic = $this->database->external_db_url; } } @@ -204,6 +182,7 @@ class General extends Component StopDatabaseProxy::run($this->database); $this->dispatch('success', 'Database is no longer publicly accessible.'); } + $this->dispatch('databaseUpdated'); } catch (\Throwable $e) { $this->isPublic = ! $this->isPublic; $this->syncData(true); @@ -212,9 +191,13 @@ class General extends Component } } - public function databaseProxyStopped() + public function databaseProxyStopped(): void { - $this->syncData(); + $this->database->refresh(); + $this->isPublic = $this->database->is_public; + $this->publicPort = $this->database->public_port; + $this->publicPortTimeout = $this->database->public_port_timeout; + $this->dispatch('databaseUpdated'); } public function submit() @@ -230,6 +213,7 @@ class General extends Component } $this->syncData(true); $this->dispatch('success', 'Database updated.'); + $this->dispatch('databaseUpdated'); } catch (Exception $e) { return handleError($e, $this); } finally { @@ -241,67 +225,6 @@ class General extends Component } } - public function instantSaveSSL() - { - try { - $this->authorize('update', $this->database); - - $this->syncData(true); - $this->dispatch('success', 'SSL configuration updated.'); - } catch (Exception $e) { - return handleError($e, $this); - } - } - - public function regenerateSslCertificate() - { - try { - $this->authorize('update', $this->database); - - $existingCert = $this->database->sslCertificates()->first(); - - if (! $existingCert) { - $this->dispatch('error', 'No existing SSL certificate found for this database.'); - - return; - } - - $server = $this->database->destination->server; - - $caCert = $server->sslCertificates() - ->where('is_ca_certificate', true) - ->first(); - - if (! $caCert) { - $server->generateCaCertificate(); - $caCert = $server->sslCertificates()->where('is_ca_certificate', true)->first(); - } - - if (! $caCert) { - $this->dispatch('error', 'No CA certificate found for this database. Please generate a CA certificate for this server in the server/advanced page.'); - - return; - } - - SslHelper::generateSslCertificate( - commonName: $existingCert->common_name, - subjectAlternativeNames: $existingCert->subject_alternative_names ?? [], - resourceType: $existingCert->resource_type, - resourceId: $existingCert->resource_id, - serverId: $existingCert->server_id, - caCert: $caCert->ssl_certificate, - caKey: $caCert->ssl_private_key, - configurationDir: $existingCert->configuration_dir, - mountPath: $existingCert->mount_path, - isPemKeyFileRequired: true, - ); - - $this->dispatch('success', 'SSL certificates regenerated. Restart database to apply changes.'); - } catch (Exception $e) { - handleError($e, $this); - } - } - public function refresh(): void { $this->database->refresh(); diff --git a/app/Livewire/Project/Database/Dragonfly/StatusInfo.php b/app/Livewire/Project/Database/Dragonfly/StatusInfo.php new file mode 100644 index 0000000000..baeb3d09f6 --- /dev/null +++ b/app/Livewire/Project/Database/Dragonfly/StatusInfo.php @@ -0,0 +1,26 @@ +authorize('view', $this->database); + $this->syncData(); + } + + public function syncData(bool $toModel = false): void + { + if ($toModel) { + $this->validate(); + $this->database->health_check_enabled = $this->healthCheckEnabled; + $this->database->health_check_interval = $this->healthCheckInterval; + $this->database->health_check_timeout = $this->healthCheckTimeout; + $this->database->health_check_retries = $this->healthCheckRetries; + $this->database->health_check_start_period = $this->healthCheckStartPeriod; + $this->database->save(); + } else { + $this->healthCheckEnabled = $this->database->health_check_enabled; + $this->healthCheckInterval = $this->database->health_check_interval; + $this->healthCheckTimeout = $this->database->health_check_timeout; + $this->healthCheckRetries = $this->database->health_check_retries; + $this->healthCheckStartPeriod = $this->database->health_check_start_period; + } + } + + public function instantSave(): void + { + $this->submit(); + } + + public function submit(): void + { + $updateSuccessful = false; + + try { + $this->authorize('update', $this->database); + $this->syncData(true); + $updateSuccessful = true; + $this->dispatch('success', 'Health check updated. Restart the database to apply the changes.'); + } catch (\Throwable $e) { + handleError($e, $this); + } + + if (! $updateSuccessful) { + return; + } + + $this->markConfigurationChanged(); + } + + public function toggleHealthcheck(): void + { + $updateSuccessful = false; + + try { + $this->authorize('update', $this->database); + $this->healthCheckEnabled = ! $this->healthCheckEnabled; + $this->syncData(true); + $updateSuccessful = true; + $this->dispatch('success', 'Health check '.($this->healthCheckEnabled ? 'enabled' : 'disabled').'. Restart the database to apply the changes.'); + } catch (\Throwable $e) { + handleError($e, $this); + } + + if (! $updateSuccessful) { + return; + } + + $this->markConfigurationChanged(); + } + + private function markConfigurationChanged(): void + { + if (is_null($this->database->config_hash)) { + $this->database->isConfigurationChanged(true); + + return; + } + + $this->dispatch('configurationChanged'); + } + + public function render(): View + { + return view('livewire.project.database.health'); + } +} diff --git a/app/Livewire/Project/Database/Import.php b/app/Livewire/Project/Database/Import.php index 0fddce2746..ea04658cfd 100644 --- a/app/Livewire/Project/Database/Import.php +++ b/app/Livewire/Project/Database/Import.php @@ -2,23 +2,14 @@ namespace App\Livewire\Project\Database; -use App\Models\S3Storage; -use App\Models\Server; -use App\Models\Service; use App\Models\ServiceDatabase; use App\Models\StandaloneClickhouse; use App\Models\StandaloneDragonfly; use App\Models\StandaloneKeydb; -use App\Models\StandaloneMariadb; -use App\Models\StandaloneMongodb; -use App\Models\StandaloneMysql; -use App\Models\StandalonePostgresql; use App\Models\StandaloneRedis; -use App\Support\ValidationPatterns; +use Illuminate\Contracts\View\View; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Support\Facades\Auth; -use Illuminate\Support\Facades\Storage; -use Livewire\Attributes\Computed; use Livewire\Attributes\Locked; use Livewire\Component; @@ -26,803 +17,134 @@ class Import extends Component { use AuthorizesRequests; - /** - * Validate that a string is safe for use as an S3 bucket name. - * Allows alphanumerics, dots, dashes, and underscores. - */ - private function validateBucketName(string $bucket): bool - { - return preg_match('/^[a-zA-Z0-9.\-_]+$/', $bucket) === 1; - } - - /** - * Validate that a string is safe for use as an S3 path. - * Allows alphanumerics, dots, dashes, underscores, slashes, and common file characters. - */ - private function validateS3Path(string $path): bool - { - // Must not be empty - if (empty($path)) { - return false; - } - - // Must not contain dangerous shell metacharacters or command injection patterns - $dangerousPatterns = [ - '..', // Directory traversal - '$(', // Command substitution - '`', // Backtick command substitution - '|', // Pipe - ';', // Command separator - '&', // Background/AND - '>', // Redirect - '<', // Redirect - "\n", // Newline - "\r", // Carriage return - "\0", // Null byte - "'", // Single quote - '"', // Double quote - '\\', // Backslash - ]; - - foreach ($dangerousPatterns as $pattern) { - if (str_contains($path, $pattern)) { - return false; - } - } - - // Allow alphanumerics, dots, dashes, underscores, slashes, spaces, plus, equals, at - return preg_match('/^[a-zA-Z0-9.\-_\/\s+@=]+$/', $path) === 1; - } - - /** - * Validate that a string is safe for use as a file path on the server. - */ - private function validateServerPath(string $path): bool - { - // Must be an absolute path - if (! str_starts_with($path, '/')) { - return false; - } - - // Must not contain dangerous shell metacharacters or command injection patterns - $dangerousPatterns = [ - '..', // Directory traversal - '$(', // Command substitution - '`', // Backtick command substitution - '|', // Pipe - ';', // Command separator - '&', // Background/AND - '>', // Redirect - '<', // Redirect - "\n", // Newline - "\r", // Carriage return - "\0", // Null byte - "'", // Single quote - '"', // Double quote - '\\', // Backslash - ]; - - foreach ($dangerousPatterns as $pattern) { - if (str_contains($path, $pattern)) { - return false; - } - } - - // Allow alphanumerics, dots, dashes, underscores, slashes, and spaces - return preg_match('/^[a-zA-Z0-9.\-_\/\s]+$/', $path) === 1; - } - - public bool $unsupported = false; - - // Store IDs instead of models for proper Livewire serialization #[Locked] public ?int $resourceId = null; #[Locked] public ?string $resourceType = null; - #[Locked] - public ?int $serverId = null; - - // View-friendly properties to avoid computed property access in Blade - #[Locked] - public string $resourceUuid = ''; - public string $resourceStatus = ''; - #[Locked] - public string $resourceDbType = ''; + public string $resourceUuid = ''; - public array $parameters = []; + public bool $unsupported = false; - public array $containers = []; - - public bool $scpInProgress = false; - - public bool $importRunning = false; - - public ?string $filename = null; - - public ?string $filesize = null; - - public bool $isUploading = false; - - public int $progress = 0; - - public bool $error = false; - - #[Locked] - public string $container; - - public array $importCommands = []; - - public bool $dumpAll = false; - - public string $restoreCommandText = ''; - - public string $customLocation = ''; - - public ?int $activityId = null; - - public string $postgresqlRestoreCommand = 'pg_restore -U $POSTGRES_USER -d ${POSTGRES_DB:-${POSTGRES_USER:-postgres}}'; - - public string $mysqlRestoreCommand = 'mysql -u $MYSQL_USER -p$MYSQL_PASSWORD $MYSQL_DATABASE'; - - public string $mariadbRestoreCommand = 'mariadb -u $MARIADB_USER -p$MARIADB_PASSWORD $MARIADB_DATABASE'; - - public string $mongodbRestoreCommand = 'mongorestore --authenticationDatabase=admin --username $MONGO_INITDB_ROOT_USERNAME --password $MONGO_INITDB_ROOT_PASSWORD --uri mongodb://localhost:27017 --gzip --archive='; - - // S3 Restore properties - public array $availableS3Storages = []; - - public ?int $s3StorageId = null; - - public string $s3Path = ''; - - public ?int $s3FileSize = null; - - #[Computed] - public function resource() + public function getListeners(): array { - if ($this->resourceId === null || $this->resourceType === null) { - return null; + $listeners = ['databaseUpdated' => 'refreshStatus']; + + $user = Auth::user(); + if (! $user) { + return $listeners; } - return $this->resourceType::find($this->resourceId); - } + $listeners["echo-private:user.{$user->id},DatabaseStatusChanged"] = 'refreshStatus'; - #[Computed] - public function server() - { - if ($this->serverId === null) { - return null; + $team = $user->currentTeam(); + if ($team) { + $listeners["echo-private:team.{$team->id},ServiceChecked"] = 'refreshStatus'; } - return Server::ownedByCurrentTeam()->find($this->serverId); + return $listeners; } - public function getListeners() + public function mount(): void { - $userId = Auth::id(); - - return [ - "echo-private:user.{$userId},DatabaseStatusChanged" => '$refresh', - 'slideOverClosed' => 'resetActivityId', - ]; - } - - public function resetActivityId() - { - $this->activityId = null; - } - - public function mount() - { - $this->parameters = get_route_parameters(); - $this->getContainers(); - $this->loadAvailableS3Storages(); - } - - public function updatedDumpAll($value) - { - $morphClass = $this->resource->getMorphClass(); - - // Handle ServiceDatabase by checking the database type - if ($morphClass === ServiceDatabase::class) { - $dbType = $this->resource->databaseType(); - if (str_contains($dbType, 'mysql')) { - $morphClass = 'mysql'; - } elseif (str_contains($dbType, 'mariadb')) { - $morphClass = 'mariadb'; - } elseif (str_contains($dbType, 'postgres')) { - $morphClass = 'postgresql'; - } - } - - switch ($morphClass) { - case StandaloneMariadb::class: - case 'mariadb': - if ($value === true) { - $this->mariadbRestoreCommand = <<<'EOD' -for pid in $(mariadb -u root -p$MARIADB_ROOT_PASSWORD -N -e "SELECT id FROM information_schema.processlist WHERE user != 'root';"); do - mariadb -u root -p$MARIADB_ROOT_PASSWORD -e "KILL $pid" 2>/dev/null || true -done && \ -mariadb -u root -p$MARIADB_ROOT_PASSWORD -N -e "SELECT CONCAT('DROP DATABASE IF EXISTS \`',schema_name,'\`;') FROM information_schema.schemata WHERE schema_name NOT IN ('information_schema','mysql','performance_schema','sys');" | mariadb -u root -p$MARIADB_ROOT_PASSWORD && \ -mariadb -u root -p$MARIADB_ROOT_PASSWORD -e "CREATE DATABASE IF NOT EXISTS \`${MARIADB_DATABASE:-default}\`;" && \ -(gunzip -cf $tmpPath 2>/dev/null || cat $tmpPath) | sed -e '/^CREATE DATABASE/d' -e '/^USE \`mysql\`/d' | mariadb -u root -p$MARIADB_ROOT_PASSWORD ${MARIADB_DATABASE:-default} -EOD; - $this->restoreCommandText = $this->mariadbRestoreCommand.' && (gunzip -cf 2>/dev/null || cat ) | mariadb -u root -p$MARIADB_ROOT_PASSWORD ${MARIADB_DATABASE:-default}'; - } else { - $this->mariadbRestoreCommand = 'mariadb -u $MARIADB_USER -p$MARIADB_PASSWORD $MARIADB_DATABASE'; - } - break; - case StandaloneMysql::class: - case 'mysql': - if ($value === true) { - $this->mysqlRestoreCommand = <<<'EOD' -for pid in $(mysql -u root -p$MYSQL_ROOT_PASSWORD -N -e "SELECT id FROM information_schema.processlist WHERE user != 'root';"); do - mysql -u root -p$MYSQL_ROOT_PASSWORD -e "KILL $pid" 2>/dev/null || true -done && \ -mysql -u root -p$MYSQL_ROOT_PASSWORD -N -e "SELECT CONCAT('DROP DATABASE IF EXISTS \`',schema_name,'\`;') FROM information_schema.schemata WHERE schema_name NOT IN ('information_schema','mysql','performance_schema','sys');" | mysql -u root -p$MYSQL_ROOT_PASSWORD && \ -mysql -u root -p$MYSQL_ROOT_PASSWORD -e "CREATE DATABASE IF NOT EXISTS \`${MYSQL_DATABASE:-default}\`;" && \ -(gunzip -cf $tmpPath 2>/dev/null || cat $tmpPath) | sed -e '/^CREATE DATABASE/d' -e '/^USE \`mysql\`/d' | mysql -u root -p$MYSQL_ROOT_PASSWORD ${MYSQL_DATABASE:-default} -EOD; - $this->restoreCommandText = $this->mysqlRestoreCommand.' && (gunzip -cf 2>/dev/null || cat ) | mysql -u root -p$MYSQL_ROOT_PASSWORD ${MYSQL_DATABASE:-default}'; - } else { - $this->mysqlRestoreCommand = 'mysql -u $MYSQL_USER -p$MYSQL_PASSWORD $MYSQL_DATABASE'; - } - break; - case StandalonePostgresql::class: - case 'postgresql': - if ($value === true) { - $this->postgresqlRestoreCommand = <<<'EOD' -psql -U ${POSTGRES_USER} -c "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname IS NOT NULL AND pid <> pg_backend_pid()" && \ -psql -U ${POSTGRES_USER} -t -c "SELECT datname FROM pg_database WHERE NOT datistemplate" | xargs -I {} dropdb -U ${POSTGRES_USER} --if-exists {} && \ -createdb -U ${POSTGRES_USER} ${POSTGRES_DB:-${POSTGRES_USER:-postgres}} -EOD; - $this->restoreCommandText = $this->postgresqlRestoreCommand.' && (gunzip -cf 2>/dev/null || cat ) | psql -U ${POSTGRES_USER} -d ${POSTGRES_DB:-${POSTGRES_USER:-postgres}}'; - } else { - $this->postgresqlRestoreCommand = 'pg_restore -U ${POSTGRES_USER} -d ${POSTGRES_DB:-${POSTGRES_USER:-postgres}}'; - } - break; - } - - } - - public function getContainers() - { - $this->containers = []; - $teamId = data_get(auth()->user()->currentTeam(), 'id'); - - // Try to find resource by route parameter - $databaseUuid = data_get($this->parameters, 'database_uuid'); - $stackServiceUuid = data_get($this->parameters, 'stack_service_uuid'); - - $resource = null; - if ($databaseUuid) { - // Standalone database route - $resource = getResourceByUuid($databaseUuid, $teamId); - if (is_null($resource)) { - abort(404); - } - } elseif ($stackServiceUuid) { - // ServiceDatabase route - look up the service database - $serviceUuid = data_get($this->parameters, 'service_uuid'); - $project = currentTeam() - ->projects() - ->select('id', 'uuid', 'team_id') - ->where('uuid', data_get($this->parameters, 'project_uuid')) - ->firstOrFail(); - $environment = $project->environments() - ->select('id', 'uuid', 'name', 'project_id') - ->where('uuid', data_get($this->parameters, 'environment_uuid')) - ->firstOrFail(); - $service = $environment->services()->whereUuid($serviceUuid)->firstOrFail(); - $resource = $service->databases()->whereUuid($stackServiceUuid)->first(); - if (is_null($resource)) { - abort(404); - } - } else { - abort(404); - } - + $resource = $this->resolveResourceFromRoute(); $this->authorize('view', $resource); - // Store IDs for Livewire serialization $this->resourceId = $resource->id; $this->resourceType = get_class($resource); - // Store view-friendly properties + $this->refreshStatus(); + } + + public function refreshStatus(): void + { + $resource = $this->resolveStoredResource(); + $this->authorize('view', $resource); + + $resource->refresh(); + $this->resourceUuid = $resource->uuid; $this->resourceStatus = $resource->status ?? ''; + $this->unsupported = $this->isUnsupportedResource($resource); + } - // Handle ServiceDatabase server access differently - if ($resource->getMorphClass() === ServiceDatabase::class) { - $server = $resource->service?->server; - if (! $server) { - abort(404, 'Server not found for this service database.'); - } - $this->serverId = $server->id; - $this->container = $resource->name.'-'.$resource->service->uuid; - $this->resourceUuid = $resource->uuid; // Use ServiceDatabase's own UUID + public function render(): View + { + return view('livewire.project.database.import'); + } - // Determine database type for ServiceDatabase - $dbType = $resource->databaseType(); - if (str_contains($dbType, 'postgres')) { - $this->resourceDbType = 'standalone-postgresql'; - } elseif (str_contains($dbType, 'mysql')) { - $this->resourceDbType = 'standalone-mysql'; - } elseif (str_contains($dbType, 'mariadb')) { - $this->resourceDbType = 'standalone-mariadb'; - } elseif (str_contains($dbType, 'mongo')) { - $this->resourceDbType = 'standalone-mongodb'; - } else { - $this->resourceDbType = $dbType; + private function resolveResourceFromRoute(): object + { + $parameters = get_route_parameters(); + $teamId = data_get(Auth::user()?->currentTeam(), 'id'); + $databaseUuid = data_get($parameters, 'database_uuid'); + $stackServiceUuid = data_get($parameters, 'stack_service_uuid'); + + if ($databaseUuid) { + $resource = getResourceByUuid($databaseUuid, $teamId); + if ($resource) { + return $resource; } - } else { - $server = $resource->destination?->server; - if (! $server) { - abort(404, 'Server not found for this database.'); - } - $this->serverId = $server->id; - $this->container = $resource->uuid; - $this->resourceUuid = $resource->uuid; - $this->resourceDbType = $resource->type(); + + abort(404); } - if (str($resource->status)->startsWith('running')) { - $this->containers[] = $this->container; + if ($stackServiceUuid) { + $project = currentTeam() + ->projects() + ->select('id', 'uuid', 'team_id') + ->where('uuid', data_get($parameters, 'project_uuid')) + ->firstOrFail(); + $environment = $project->environments() + ->select('id', 'uuid', 'name', 'project_id') + ->where('uuid', data_get($parameters, 'environment_uuid')) + ->firstOrFail(); + $service = $environment->services()->whereUuid(data_get($parameters, 'service_uuid'))->firstOrFail(); + $resource = $service->databases()->whereUuid($stackServiceUuid)->first(); + if ($resource) { + return $resource; + } } + abort(404); + } + + private function resolveStoredResource(): object + { + if ($this->resourceId === null || $this->resourceType === null) { + return $this->resolveResourceFromRoute(); + } + + $resource = $this->resourceType::find($this->resourceId); + if ($resource) { + return $resource; + } + + abort(404); + } + + private function isUnsupportedResource(object $resource): bool + { if ( - $resource->getMorphClass() === StandaloneRedis::class || - $resource->getMorphClass() === StandaloneKeydb::class || - $resource->getMorphClass() === StandaloneDragonfly::class || - $resource->getMorphClass() === StandaloneClickhouse::class + $resource instanceof StandaloneRedis || + $resource instanceof StandaloneKeydb || + $resource instanceof StandaloneDragonfly || + $resource instanceof StandaloneClickhouse ) { - $this->unsupported = true; + return true; } - // Mark unsupported ServiceDatabase types (Redis, KeyDB, etc.) - if ($resource->getMorphClass() === ServiceDatabase::class) { + if ($resource instanceof ServiceDatabase) { $dbType = $resource->databaseType(); - if (str_contains($dbType, 'redis') || str_contains($dbType, 'keydb') || - str_contains($dbType, 'dragonfly') || str_contains($dbType, 'clickhouse')) { - $this->unsupported = true; - } - } - } - public function checkFile() - { - if (filled($this->customLocation)) { - // Validate the custom location to prevent command injection - if (! $this->validateServerPath($this->customLocation)) { - $this->dispatch('error', 'Invalid file path. Path must be absolute and contain only safe characters (alphanumerics, dots, dashes, underscores, slashes).'); - - return; - } - - if (! $this->server) { - $this->dispatch('error', 'Server not found. Please refresh the page.'); - - return; - } - - try { - $escapedPath = escapeshellarg($this->customLocation); - $result = instant_remote_process(["ls -l {$escapedPath}"], $this->server, throwError: false); - if (blank($result)) { - $this->dispatch('error', 'The file does not exist or has been deleted.'); - - return; - } - $this->filename = $this->customLocation; - $this->dispatch('success', 'The file exists.'); - } catch (\Throwable $e) { - return handleError($e, $this); - } - } - } - - public function runImport(string $password = ''): bool|string - { - if (! verifyPasswordConfirmation($password, $this)) { - return 'The provided password is incorrect.'; + return str_contains($dbType, 'redis') || + str_contains($dbType, 'keydb') || + str_contains($dbType, 'dragonfly') || + str_contains($dbType, 'clickhouse'); } - $this->authorize('update', $this->resource); - - if (! ValidationPatterns::isValidContainerName($this->container)) { - $this->dispatch('error', 'Invalid container name.'); - - return true; - } - - if ($this->filename === '') { - $this->dispatch('error', 'Please select a file to import.'); - - return true; - } - - if (! $this->server) { - $this->dispatch('error', 'Server not found. Please refresh the page.'); - - return true; - } - - try { - $this->importRunning = true; - $this->importCommands = []; - $backupFileName = "upload/{$this->resourceUuid}/restore"; - - // Check if an uploaded file exists first (takes priority over custom location) - if (Storage::exists($backupFileName)) { - $path = Storage::path($backupFileName); - $tmpPath = '/tmp/'.basename($backupFileName).'_'.$this->resourceUuid; - instant_scp($path, $tmpPath, $this->server); - Storage::delete($backupFileName); - $this->importCommands[] = "docker cp {$tmpPath} {$this->container}:{$tmpPath}"; - } elseif (filled($this->customLocation)) { - // Validate the custom location to prevent command injection - if (! $this->validateServerPath($this->customLocation)) { - $this->dispatch('error', 'Invalid file path. Path must be absolute and contain only safe characters.'); - - return true; - } - $tmpPath = '/tmp/restore_'.$this->resourceUuid; - $escapedCustomLocation = escapeshellarg($this->customLocation); - $this->importCommands[] = "docker cp {$escapedCustomLocation} {$this->container}:{$tmpPath}"; - } else { - $this->dispatch('error', 'The file does not exist or has been deleted.'); - - return true; - } - - // Copy the restore command to a script file - $scriptPath = "/tmp/restore_{$this->resourceUuid}.sh"; - - $restoreCommand = $this->buildRestoreCommand($tmpPath); - - $restoreCommandBase64 = base64_encode($restoreCommand); - $this->importCommands[] = "echo \"{$restoreCommandBase64}\" | base64 -d > {$scriptPath}"; - $this->importCommands[] = "chmod +x {$scriptPath}"; - $this->importCommands[] = "docker cp {$scriptPath} {$this->container}:{$scriptPath}"; - - $this->importCommands[] = "docker exec {$this->container} sh -c '{$scriptPath}'"; - $this->importCommands[] = "docker exec {$this->container} sh -c 'echo \"Import finished with exit code $?\"'"; - - if (! empty($this->importCommands)) { - $activity = remote_process($this->importCommands, $this->server, ignore_errors: true, callEventOnFinish: 'RestoreJobFinished', callEventData: [ - 'scriptPath' => $scriptPath, - 'tmpPath' => $tmpPath, - 'container' => $this->container, - 'serverId' => $this->server->id, - ]); - - // Track the activity ID - $this->activityId = $activity->id; - - // Dispatch activity to the monitor and open slide-over - $this->dispatch('activityMonitor', $activity->id); - $this->dispatch('databaserestore'); - } - } catch (\Throwable $e) { - handleError($e, $this); - - return true; - } finally { - $this->filename = null; - $this->importCommands = []; - } - - return true; - } - - public function loadAvailableS3Storages() - { - try { - $this->availableS3Storages = S3Storage::ownedByCurrentTeam(['id', 'name', 'description']) - ->where('is_usable', true) - ->get() - ->map(fn ($s) => ['id' => $s->id, 'name' => $s->name, 'description' => $s->description]) - ->toArray(); - } catch (\Throwable $e) { - $this->availableS3Storages = []; - } - } - - public function updatedS3Path($value) - { - // Reset validation state when path changes - $this->s3FileSize = null; - - // Ensure path starts with a slash - if ($value !== null && $value !== '') { - $this->s3Path = str($value)->trim()->start('/')->value(); - } - } - - public function updatedS3StorageId() - { - // Reset validation state when storage changes - $this->s3FileSize = null; - } - - public function checkS3File() - { - if (! $this->s3StorageId) { - $this->dispatch('error', 'Please select an S3 storage.'); - - return; - } - - if (blank($this->s3Path)) { - $this->dispatch('error', 'Please provide an S3 path.'); - - return; - } - - // Clean the path (remove leading slash if present) - $cleanPath = ltrim($this->s3Path, '/'); - - // Validate the S3 path early to prevent command injection in subsequent operations - if (! $this->validateS3Path($cleanPath)) { - $this->dispatch('error', 'Invalid S3 path. Path must contain only safe characters (alphanumerics, dots, dashes, underscores, slashes).'); - - return; - } - - try { - $s3Storage = S3Storage::ownedByCurrentTeam()->findOrFail($this->s3StorageId); - - // Validate bucket name early - if (! $this->validateBucketName($s3Storage->bucket)) { - $this->dispatch('error', 'Invalid S3 bucket name. Bucket name must contain only alphanumerics, dots, dashes, and underscores.'); - - return; - } - - // Test connection - $s3Storage->testConnection(); - - // Build S3 disk configuration - $disk = Storage::build([ - 'driver' => 's3', - 'region' => $s3Storage->region, - 'key' => $s3Storage->key, - 'secret' => $s3Storage->secret, - 'bucket' => $s3Storage->bucket, - 'endpoint' => $s3Storage->endpoint, - 'use_path_style_endpoint' => true, - ]); - - // Check if file exists - if (! $disk->exists($cleanPath)) { - $this->dispatch('error', 'File not found in S3. Please check the path.'); - - return; - } - - // Get file size - $this->s3FileSize = $disk->size($cleanPath); - - $this->dispatch('success', 'File found in S3. Size: '.formatBytes($this->s3FileSize)); - } catch (\Throwable $e) { - $this->s3FileSize = null; - - return handleError($e, $this); - } - } - - public function restoreFromS3(string $password = ''): bool|string - { - if (! verifyPasswordConfirmation($password, $this)) { - return 'The provided password is incorrect.'; - } - - $this->authorize('update', $this->resource); - - if (! ValidationPatterns::isValidContainerName($this->container)) { - $this->dispatch('error', 'Invalid container name.'); - - return true; - } - - if (! $this->s3StorageId || blank($this->s3Path)) { - $this->dispatch('error', 'Please select S3 storage and provide a path first.'); - - return true; - } - - if (is_null($this->s3FileSize)) { - $this->dispatch('error', 'Please check the file first by clicking "Check File".'); - - return true; - } - - if (! $this->server) { - $this->dispatch('error', 'Server not found. Please refresh the page.'); - - return true; - } - - try { - $this->importRunning = true; - - $s3Storage = S3Storage::ownedByCurrentTeam()->findOrFail($this->s3StorageId); - - $key = $s3Storage->key; - $secret = $s3Storage->secret; - $bucket = $s3Storage->bucket; - $endpoint = $s3Storage->endpoint; - - // Validate bucket name to prevent command injection - if (! $this->validateBucketName($bucket)) { - $this->dispatch('error', 'Invalid S3 bucket name. Bucket name must contain only alphanumerics, dots, dashes, and underscores.'); - - return true; - } - - // Clean the S3 path - $cleanPath = ltrim($this->s3Path, '/'); - - // Validate the S3 path to prevent command injection - if (! $this->validateS3Path($cleanPath)) { - $this->dispatch('error', 'Invalid S3 path. Path must contain only safe characters (alphanumerics, dots, dashes, underscores, slashes).'); - - return true; - } - - // Get helper image - $helperImage = config('constants.coolify.helper_image'); - $latestVersion = getHelperVersion(); - $fullImageName = "{$helperImage}:{$latestVersion}"; - - // Get the database destination network - if ($this->resource->getMorphClass() === ServiceDatabase::class) { - $destinationNetwork = $this->resource->service->destination->network ?? 'coolify'; - } else { - $destinationNetwork = $this->resource->destination->network ?? 'coolify'; - } - - // Generate unique names for this operation - $containerName = "s3-restore-{$this->resourceUuid}"; - $helperTmpPath = '/tmp/'.basename($cleanPath); - $serverTmpPath = "/tmp/s3-restore-{$this->resourceUuid}-".basename($cleanPath); - $containerTmpPath = "/tmp/restore_{$this->resourceUuid}-".basename($cleanPath); - $scriptPath = "/tmp/restore_{$this->resourceUuid}.sh"; - - // Prepare all commands in sequence - $commands = []; - - // 1. Clean up any existing helper container and temp files from previous runs - $commands[] = "docker rm -f {$containerName} 2>/dev/null || true"; - $commands[] = "rm -f {$serverTmpPath} 2>/dev/null || true"; - $commands[] = "docker exec {$this->container} rm -f {$containerTmpPath} {$scriptPath} 2>/dev/null || true"; - - // 2. Start helper container on the database network - $commands[] = "docker run -d --network {$destinationNetwork} --name {$containerName} {$fullImageName} sleep 3600"; - - // 3. Configure S3 access in helper container - $escapedEndpoint = escapeshellarg($endpoint); - $escapedKey = escapeshellarg($key); - $escapedSecret = escapeshellarg($secret); - $commands[] = "docker exec {$containerName} mc alias set s3temp {$escapedEndpoint} {$escapedKey} {$escapedSecret}"; - - // 4. Check file exists in S3 (bucket and path already validated above) - $escapedBucket = escapeshellarg($bucket); - $escapedCleanPath = escapeshellarg($cleanPath); - $escapedS3Source = escapeshellarg("s3temp/{$bucket}/{$cleanPath}"); - $commands[] = "docker exec {$containerName} mc stat {$escapedS3Source}"; - - // 5. Download from S3 to helper container (progress shown by default) - $escapedHelperTmpPath = escapeshellarg($helperTmpPath); - $commands[] = "docker exec {$containerName} mc cp {$escapedS3Source} {$escapedHelperTmpPath}"; - - // 6. Copy from helper to server, then immediately to database container - $commands[] = "docker cp {$containerName}:{$helperTmpPath} {$serverTmpPath}"; - $commands[] = "docker cp {$serverTmpPath} {$this->container}:{$containerTmpPath}"; - - // 7. Cleanup helper container and server temp file immediately (no longer needed) - $commands[] = "docker rm -f {$containerName} 2>/dev/null || true"; - $commands[] = "rm -f {$serverTmpPath} 2>/dev/null || true"; - - // 8. Build and execute restore command inside database container - $restoreCommand = $this->buildRestoreCommand($containerTmpPath); - - $restoreCommandBase64 = base64_encode($restoreCommand); - $commands[] = "echo \"{$restoreCommandBase64}\" | base64 -d > {$scriptPath}"; - $commands[] = "chmod +x {$scriptPath}"; - $commands[] = "docker cp {$scriptPath} {$this->container}:{$scriptPath}"; - - // 9. Execute restore and cleanup temp files immediately after completion - $commands[] = "docker exec {$this->container} sh -c '{$scriptPath} && rm -f {$containerTmpPath} {$scriptPath}'"; - $commands[] = "docker exec {$this->container} sh -c 'echo \"Import finished with exit code $?\"'"; - - // Execute all commands with cleanup event (as safety net for edge cases) - $activity = remote_process($commands, $this->server, ignore_errors: true, callEventOnFinish: 'S3RestoreJobFinished', callEventData: [ - 'containerName' => $containerName, - 'serverTmpPath' => $serverTmpPath, - 'scriptPath' => $scriptPath, - 'containerTmpPath' => $containerTmpPath, - 'container' => $this->container, - 'serverId' => $this->server->id, - ]); - - // Track the activity ID - $this->activityId = $activity->id; - - // Dispatch activity to the monitor and open slide-over - $this->dispatch('activityMonitor', $activity->id); - $this->dispatch('databaserestore'); - $this->dispatch('info', 'Restoring database from S3. Progress will be shown in the activity monitor...'); - } catch (\Throwable $e) { - $this->importRunning = false; - handleError($e, $this); - - return true; - } - - return true; - } - - public function buildRestoreCommand(string $tmpPath): string - { - $morphClass = $this->resource->getMorphClass(); - - // Handle ServiceDatabase by checking the database type - if ($morphClass === ServiceDatabase::class) { - $dbType = $this->resource->databaseType(); - if (str_contains($dbType, 'mysql')) { - $morphClass = 'mysql'; - } elseif (str_contains($dbType, 'mariadb')) { - $morphClass = 'mariadb'; - } elseif (str_contains($dbType, 'postgres')) { - $morphClass = 'postgresql'; - } elseif (str_contains($dbType, 'mongo')) { - $morphClass = 'mongodb'; - } - } - - switch ($morphClass) { - case StandaloneMariadb::class: - case 'mariadb': - $restoreCommand = $this->mariadbRestoreCommand; - if ($this->dumpAll) { - $restoreCommand .= " && (gunzip -cf {$tmpPath} 2>/dev/null || cat {$tmpPath}) | mariadb -u root -p\$MARIADB_ROOT_PASSWORD \${MARIADB_DATABASE:-default}"; - } else { - $restoreCommand .= " < {$tmpPath}"; - } - break; - case StandaloneMysql::class: - case 'mysql': - $restoreCommand = $this->mysqlRestoreCommand; - if ($this->dumpAll) { - $restoreCommand .= " && (gunzip -cf {$tmpPath} 2>/dev/null || cat {$tmpPath}) | mysql -u root -p\$MYSQL_ROOT_PASSWORD \${MYSQL_DATABASE:-default}"; - } else { - $restoreCommand .= " < {$tmpPath}"; - } - break; - case StandalonePostgresql::class: - case 'postgresql': - $restoreCommand = $this->postgresqlRestoreCommand; - if ($this->dumpAll) { - $restoreCommand .= " && (gunzip -cf {$tmpPath} 2>/dev/null || cat {$tmpPath}) | psql -U \${POSTGRES_USER} -d \${POSTGRES_DB:-\${POSTGRES_USER:-postgres}}"; - } else { - $restoreCommand .= " {$tmpPath}"; - } - break; - case StandaloneMongodb::class: - case 'mongodb': - $restoreCommand = $this->mongodbRestoreCommand; - if ($this->dumpAll === false) { - $restoreCommand .= "{$tmpPath}"; - } - break; - default: - $restoreCommand = ''; - } - - return $restoreCommand; + return false; } } diff --git a/app/Livewire/Project/Database/ImportForm.php b/app/Livewire/Project/Database/ImportForm.php new file mode 100644 index 0000000000..ccc7b347d0 --- /dev/null +++ b/app/Livewire/Project/Database/ImportForm.php @@ -0,0 +1,825 @@ +', // Redirect + '<', // Redirect + "\n", // Newline + "\r", // Carriage return + "\0", // Null byte + "'", // Single quote + '"', // Double quote + '\\', // Backslash + ]; + + foreach ($dangerousPatterns as $pattern) { + if (str_contains($path, $pattern)) { + return false; + } + } + + // Allow alphanumerics, dots, dashes, underscores, slashes, spaces, plus, equals, at + return preg_match('/^[a-zA-Z0-9.\-_\/\s+@=]+$/', $path) === 1; + } + + /** + * Validate that a string is safe for use as a file path on the server. + */ + private function validateServerPath(string $path): bool + { + // Must be an absolute path + if (! str_starts_with($path, '/')) { + return false; + } + + // Must not contain dangerous shell metacharacters or command injection patterns + $dangerousPatterns = [ + '..', // Directory traversal + '$(', // Command substitution + '`', // Backtick command substitution + '|', // Pipe + ';', // Command separator + '&', // Background/AND + '>', // Redirect + '<', // Redirect + "\n", // Newline + "\r", // Carriage return + "\0", // Null byte + "'", // Single quote + '"', // Double quote + '\\', // Backslash + ]; + + foreach ($dangerousPatterns as $pattern) { + if (str_contains($path, $pattern)) { + return false; + } + } + + // Allow alphanumerics, dots, dashes, underscores, slashes, and spaces + return preg_match('/^[a-zA-Z0-9.\-_\/\s]+$/', $path) === 1; + } + + public bool $unsupported = false; + + // Store IDs instead of models for proper Livewire serialization + #[Locked] + public ?int $resourceId = null; + + #[Locked] + public ?string $resourceType = null; + + #[Locked] + public ?int $serverId = null; + + // View-friendly properties to avoid computed property access in Blade + #[Locked] + public string $resourceUuid = ''; + + public string $resourceStatus = ''; + + #[Locked] + public string $resourceDbType = ''; + + public array $parameters = []; + + public array $containers = []; + + public bool $scpInProgress = false; + + public bool $importRunning = false; + + public ?string $filename = null; + + public ?string $filesize = null; + + public bool $isUploading = false; + + public int $progress = 0; + + public bool $error = false; + + #[Locked] + public string $container; + + public array $importCommands = []; + + public bool $dumpAll = false; + + public string $restoreCommandText = ''; + + public string $customLocation = ''; + + public ?int $activityId = null; + + public string $postgresqlRestoreCommand = 'pg_restore -U $POSTGRES_USER -d ${POSTGRES_DB:-${POSTGRES_USER:-postgres}}'; + + public string $mysqlRestoreCommand = 'mysql -u $MYSQL_USER -p$MYSQL_PASSWORD $MYSQL_DATABASE'; + + public string $mariadbRestoreCommand = 'mariadb -u $MARIADB_USER -p$MARIADB_PASSWORD $MARIADB_DATABASE'; + + public string $mongodbRestoreCommand = 'mongorestore --authenticationDatabase=admin --username $MONGO_INITDB_ROOT_USERNAME --password $MONGO_INITDB_ROOT_PASSWORD --uri mongodb://localhost:27017 --gzip --archive='; + + // S3 Restore properties + public array $availableS3Storages = []; + + public ?int $s3StorageId = null; + + public string $s3Path = ''; + + public ?int $s3FileSize = null; + + #[Computed] + public function resource() + { + if ($this->resourceId === null || $this->resourceType === null) { + return null; + } + + return $this->resourceType::find($this->resourceId); + } + + #[Computed] + public function server() + { + if ($this->serverId === null) { + return null; + } + + return Server::ownedByCurrentTeam()->find($this->serverId); + } + + protected $listeners = [ + 'slideOverClosed' => 'resetActivityId', + ]; + + public function resetActivityId() + { + $this->activityId = null; + } + + public function mount() + { + $this->parameters = get_route_parameters(); + $this->getContainers(); + $this->loadAvailableS3Storages(); + } + + public function updatedDumpAll($value) + { + $morphClass = $this->resource->getMorphClass(); + + // Handle ServiceDatabase by checking the database type + if ($morphClass === ServiceDatabase::class) { + $dbType = $this->resource->databaseType(); + if (str_contains($dbType, 'mysql')) { + $morphClass = 'mysql'; + } elseif (str_contains($dbType, 'mariadb')) { + $morphClass = 'mariadb'; + } elseif (str_contains($dbType, 'postgres')) { + $morphClass = 'postgresql'; + } + } + + switch ($morphClass) { + case StandaloneMariadb::class: + case 'mariadb': + if ($value === true) { + $this->mariadbRestoreCommand = <<<'EOD' +for pid in $(mariadb -u root -p$MARIADB_ROOT_PASSWORD -N -e "SELECT id FROM information_schema.processlist WHERE user != 'root';"); do + mariadb -u root -p$MARIADB_ROOT_PASSWORD -e "KILL $pid" 2>/dev/null || true +done && \ +mariadb -u root -p$MARIADB_ROOT_PASSWORD -N -e "SELECT CONCAT('DROP DATABASE IF EXISTS \`',schema_name,'\`;') FROM information_schema.schemata WHERE schema_name NOT IN ('information_schema','mysql','performance_schema','sys');" | mariadb -u root -p$MARIADB_ROOT_PASSWORD && \ +mariadb -u root -p$MARIADB_ROOT_PASSWORD -e "CREATE DATABASE IF NOT EXISTS \`${MARIADB_DATABASE:-default}\`;" && \ +(gunzip -cf $tmpPath 2>/dev/null || cat $tmpPath) | sed -e '/^CREATE DATABASE/d' -e '/^USE \`mysql\`/d' | mariadb -u root -p$MARIADB_ROOT_PASSWORD ${MARIADB_DATABASE:-default} +EOD; + $this->restoreCommandText = $this->mariadbRestoreCommand.' && (gunzip -cf 2>/dev/null || cat ) | mariadb -u root -p$MARIADB_ROOT_PASSWORD ${MARIADB_DATABASE:-default}'; + } else { + $this->mariadbRestoreCommand = 'mariadb -u $MARIADB_USER -p$MARIADB_PASSWORD $MARIADB_DATABASE'; + } + break; + case StandaloneMysql::class: + case 'mysql': + if ($value === true) { + $this->mysqlRestoreCommand = <<<'EOD' +for pid in $(mysql -u root -p$MYSQL_ROOT_PASSWORD -N -e "SELECT id FROM information_schema.processlist WHERE user != 'root';"); do + mysql -u root -p$MYSQL_ROOT_PASSWORD -e "KILL $pid" 2>/dev/null || true +done && \ +mysql -u root -p$MYSQL_ROOT_PASSWORD -N -e "SELECT CONCAT('DROP DATABASE IF EXISTS \`',schema_name,'\`;') FROM information_schema.schemata WHERE schema_name NOT IN ('information_schema','mysql','performance_schema','sys');" | mysql -u root -p$MYSQL_ROOT_PASSWORD && \ +mysql -u root -p$MYSQL_ROOT_PASSWORD -e "CREATE DATABASE IF NOT EXISTS \`${MYSQL_DATABASE:-default}\`;" && \ +(gunzip -cf $tmpPath 2>/dev/null || cat $tmpPath) | sed -e '/^CREATE DATABASE/d' -e '/^USE \`mysql\`/d' | mysql -u root -p$MYSQL_ROOT_PASSWORD ${MYSQL_DATABASE:-default} +EOD; + $this->restoreCommandText = $this->mysqlRestoreCommand.' && (gunzip -cf 2>/dev/null || cat ) | mysql -u root -p$MYSQL_ROOT_PASSWORD ${MYSQL_DATABASE:-default}'; + } else { + $this->mysqlRestoreCommand = 'mysql -u $MYSQL_USER -p$MYSQL_PASSWORD $MYSQL_DATABASE'; + } + break; + case StandalonePostgresql::class: + case 'postgresql': + if ($value === true) { + $this->postgresqlRestoreCommand = <<<'EOD' +psql -U ${POSTGRES_USER} -c "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname IS NOT NULL AND pid <> pg_backend_pid()" && \ +psql -U ${POSTGRES_USER} -t -c "SELECT datname FROM pg_database WHERE NOT datistemplate" | xargs -I {} dropdb -U ${POSTGRES_USER} --if-exists {} && \ +createdb -U ${POSTGRES_USER} ${POSTGRES_DB:-${POSTGRES_USER:-postgres}} +EOD; + $this->restoreCommandText = $this->postgresqlRestoreCommand.' && (gunzip -cf 2>/dev/null || cat ) | psql -U ${POSTGRES_USER} -d ${POSTGRES_DB:-${POSTGRES_USER:-postgres}}'; + } else { + $this->postgresqlRestoreCommand = 'pg_restore -U ${POSTGRES_USER} -d ${POSTGRES_DB:-${POSTGRES_USER:-postgres}}'; + } + break; + } + + } + + public function getContainers() + { + $this->containers = []; + $teamId = data_get(auth()->user()->currentTeam(), 'id'); + + // Try to find resource by route parameter + $databaseUuid = data_get($this->parameters, 'database_uuid'); + $stackServiceUuid = data_get($this->parameters, 'stack_service_uuid'); + + $resource = null; + if ($databaseUuid) { + // Standalone database route + $resource = getResourceByUuid($databaseUuid, $teamId); + if (is_null($resource)) { + abort(404); + } + } elseif ($stackServiceUuid) { + // ServiceDatabase route - look up the service database + $serviceUuid = data_get($this->parameters, 'service_uuid'); + $project = currentTeam() + ->projects() + ->select('id', 'uuid', 'team_id') + ->where('uuid', data_get($this->parameters, 'project_uuid')) + ->firstOrFail(); + $environment = $project->environments() + ->select('id', 'uuid', 'name', 'project_id') + ->where('uuid', data_get($this->parameters, 'environment_uuid')) + ->firstOrFail(); + $service = $environment->services()->whereUuid($serviceUuid)->firstOrFail(); + $resource = $service->databases()->whereUuid($stackServiceUuid)->first(); + if (is_null($resource)) { + abort(404); + } + } else { + abort(404); + } + + $this->authorize('view', $resource); + + // Store IDs for Livewire serialization + $this->resourceId = $resource->id; + $this->resourceType = get_class($resource); + + // Store view-friendly properties + $this->resourceStatus = $resource->status ?? ''; + + // Handle ServiceDatabase server access differently + if ($resource->getMorphClass() === ServiceDatabase::class) { + $server = $resource->service?->server; + if (! $server) { + abort(404, 'Server not found for this service database.'); + } + $this->serverId = $server->id; + $this->container = $resource->name.'-'.$resource->service->uuid; + $this->resourceUuid = $resource->uuid; // Use ServiceDatabase's own UUID + + // Determine database type for ServiceDatabase + $dbType = $resource->databaseType(); + if (str_contains($dbType, 'postgres')) { + $this->resourceDbType = 'standalone-postgresql'; + } elseif (str_contains($dbType, 'mysql')) { + $this->resourceDbType = 'standalone-mysql'; + } elseif (str_contains($dbType, 'mariadb')) { + $this->resourceDbType = 'standalone-mariadb'; + } elseif (str_contains($dbType, 'mongo')) { + $this->resourceDbType = 'standalone-mongodb'; + } else { + $this->resourceDbType = $dbType; + } + } else { + $server = $resource->destination?->server; + if (! $server) { + abort(404, 'Server not found for this database.'); + } + $this->serverId = $server->id; + $this->container = $resource->uuid; + $this->resourceUuid = $resource->uuid; + $this->resourceDbType = $resource->type(); + } + + if (str($resource->status)->startsWith('running')) { + $this->containers[] = $this->container; + } + + if ( + $resource->getMorphClass() === StandaloneRedis::class || + $resource->getMorphClass() === StandaloneKeydb::class || + $resource->getMorphClass() === StandaloneDragonfly::class || + $resource->getMorphClass() === StandaloneClickhouse::class + ) { + $this->unsupported = true; + } + + // Mark unsupported ServiceDatabase types (Redis, KeyDB, etc.) + if ($resource->getMorphClass() === ServiceDatabase::class) { + $dbType = $resource->databaseType(); + if (str_contains($dbType, 'redis') || str_contains($dbType, 'keydb') || + str_contains($dbType, 'dragonfly') || str_contains($dbType, 'clickhouse')) { + $this->unsupported = true; + } + } + } + + public function checkFile() + { + if (filled($this->customLocation)) { + // Validate the custom location to prevent command injection + if (! $this->validateServerPath($this->customLocation)) { + $this->dispatch('error', 'Invalid file path. Path must be absolute and contain only safe characters (alphanumerics, dots, dashes, underscores, slashes).'); + + return; + } + + if (! $this->server) { + $this->dispatch('error', 'Server not found. Please refresh the page.'); + + return; + } + + try { + $escapedPath = escapeshellarg($this->customLocation); + $result = instant_remote_process(["ls -l {$escapedPath}"], $this->server, throwError: false); + if (blank($result)) { + $this->dispatch('error', 'The file does not exist or has been deleted.'); + + return; + } + $this->filename = $this->customLocation; + $this->dispatch('success', 'The file exists.'); + } catch (\Throwable $e) { + return handleError($e, $this); + } + } + } + + public function runImport(string $password = ''): bool|string + { + if (! verifyPasswordConfirmation($password, $this)) { + return 'The provided password is incorrect.'; + } + + $this->authorize('update', $this->resource); + + if (! ValidationPatterns::isValidContainerName($this->container)) { + $this->dispatch('error', 'Invalid container name.'); + + return true; + } + + if ($this->filename === '') { + $this->dispatch('error', 'Please select a file to import.'); + + return true; + } + + if (! $this->server) { + $this->dispatch('error', 'Server not found. Please refresh the page.'); + + return true; + } + + try { + $this->importRunning = true; + $this->importCommands = []; + $backupFileName = "upload/{$this->resourceUuid}/restore"; + + // Check if an uploaded file exists first (takes priority over custom location) + if (Storage::exists($backupFileName)) { + $path = Storage::path($backupFileName); + $tmpPath = '/tmp/'.basename($backupFileName).'_'.$this->resourceUuid; + instant_scp($path, $tmpPath, $this->server); + Storage::delete($backupFileName); + $this->importCommands[] = "docker cp {$tmpPath} {$this->container}:{$tmpPath}"; + } elseif (filled($this->customLocation)) { + // Validate the custom location to prevent command injection + if (! $this->validateServerPath($this->customLocation)) { + $this->dispatch('error', 'Invalid file path. Path must be absolute and contain only safe characters.'); + + return true; + } + $tmpPath = '/tmp/restore_'.$this->resourceUuid; + $escapedCustomLocation = escapeshellarg($this->customLocation); + $this->importCommands[] = "docker cp {$escapedCustomLocation} {$this->container}:{$tmpPath}"; + } else { + $this->dispatch('error', 'The file does not exist or has been deleted.'); + + return true; + } + + // Copy the restore command to a script file + $scriptPath = "/tmp/restore_{$this->resourceUuid}.sh"; + + $restoreCommand = $this->buildRestoreCommand($tmpPath); + + $restoreCommandBase64 = base64_encode($restoreCommand); + $this->importCommands[] = "echo \"{$restoreCommandBase64}\" | base64 -d > {$scriptPath}"; + $this->importCommands[] = "chmod +x {$scriptPath}"; + $this->importCommands[] = "docker cp {$scriptPath} {$this->container}:{$scriptPath}"; + + $this->importCommands[] = "docker exec {$this->container} sh -c '{$scriptPath}'"; + $this->importCommands[] = "docker exec {$this->container} sh -c 'echo \"Import finished with exit code $?\"'"; + + if (! empty($this->importCommands)) { + $activity = remote_process($this->importCommands, $this->server, ignore_errors: true, callEventOnFinish: 'RestoreJobFinished', callEventData: [ + 'scriptPath' => $scriptPath, + 'tmpPath' => $tmpPath, + 'container' => $this->container, + 'serverId' => $this->server->id, + ]); + + // Track the activity ID + $this->activityId = $activity->id; + + // Dispatch activity to the monitor and open slide-over + $this->dispatch('activityMonitor', $activity->id); + $this->dispatch('databaserestore'); + } + } catch (\Throwable $e) { + handleError($e, $this); + + return true; + } finally { + $this->filename = null; + $this->importCommands = []; + } + + return true; + } + + public function loadAvailableS3Storages() + { + try { + $this->availableS3Storages = S3Storage::ownedByCurrentTeam(['id', 'name', 'description']) + ->where('is_usable', true) + ->get() + ->map(fn ($s) => ['id' => $s->id, 'name' => $s->name, 'description' => $s->description]) + ->toArray(); + } catch (\Throwable $e) { + $this->availableS3Storages = []; + } + } + + public function updatedS3Path($value) + { + // Reset validation state when path changes + $this->s3FileSize = null; + + // Ensure path starts with a slash + if ($value !== null && $value !== '') { + $this->s3Path = str($value)->trim()->start('/')->value(); + } + } + + public function updatedS3StorageId() + { + // Reset validation state when storage changes + $this->s3FileSize = null; + } + + public function checkS3File() + { + if (! $this->s3StorageId) { + $this->dispatch('error', 'Please select an S3 storage.'); + + return; + } + + if (blank($this->s3Path)) { + $this->dispatch('error', 'Please provide an S3 path.'); + + return; + } + + // Clean the path (remove leading slash if present) + $cleanPath = ltrim($this->s3Path, '/'); + + // Validate the S3 path early to prevent command injection in subsequent operations + if (! $this->validateS3Path($cleanPath)) { + $this->dispatch('error', 'Invalid S3 path. Path must contain only safe characters (alphanumerics, dots, dashes, underscores, slashes).'); + + return; + } + + try { + $s3Storage = S3Storage::ownedByCurrentTeam()->findOrFail($this->s3StorageId); + + // Validate bucket name early + if (! $this->validateBucketName($s3Storage->bucket)) { + $this->dispatch('error', 'Invalid S3 bucket name. Bucket name must contain only alphanumerics, dots, dashes, and underscores.'); + + return; + } + + // Test connection + $s3Storage->testConnection(); + + // Build S3 disk configuration + $disk = Storage::build([ + 'driver' => 's3', + 'region' => $s3Storage->region, + 'key' => $s3Storage->key, + 'secret' => $s3Storage->secret, + 'bucket' => $s3Storage->bucket, + 'endpoint' => $s3Storage->endpoint, + 'use_path_style_endpoint' => true, + ]); + + // Check if file exists + if (! $disk->exists($cleanPath)) { + $this->dispatch('error', 'File not found in S3. Please check the path.'); + + return; + } + + // Get file size + $this->s3FileSize = $disk->size($cleanPath); + + $this->dispatch('success', 'File found in S3. Size: '.formatBytes($this->s3FileSize)); + } catch (\Throwable $e) { + $this->s3FileSize = null; + + return handleError($e, $this); + } + } + + public function restoreFromS3(string $password = ''): bool|string + { + if (! verifyPasswordConfirmation($password, $this)) { + return 'The provided password is incorrect.'; + } + + $this->authorize('update', $this->resource); + + if (! ValidationPatterns::isValidContainerName($this->container)) { + $this->dispatch('error', 'Invalid container name.'); + + return true; + } + + if (! $this->s3StorageId || blank($this->s3Path)) { + $this->dispatch('error', 'Please select S3 storage and provide a path first.'); + + return true; + } + + if (is_null($this->s3FileSize)) { + $this->dispatch('error', 'Please check the file first by clicking "Check File".'); + + return true; + } + + if (! $this->server) { + $this->dispatch('error', 'Server not found. Please refresh the page.'); + + return true; + } + + try { + $this->importRunning = true; + + $s3Storage = S3Storage::ownedByCurrentTeam()->findOrFail($this->s3StorageId); + + $key = $s3Storage->key; + $secret = $s3Storage->secret; + $bucket = $s3Storage->bucket; + $endpoint = $s3Storage->endpoint; + + // Validate bucket name to prevent command injection + if (! $this->validateBucketName($bucket)) { + $this->dispatch('error', 'Invalid S3 bucket name. Bucket name must contain only alphanumerics, dots, dashes, and underscores.'); + + return true; + } + + // Clean the S3 path + $cleanPath = ltrim($this->s3Path, '/'); + + // Validate the S3 path to prevent command injection + if (! $this->validateS3Path($cleanPath)) { + $this->dispatch('error', 'Invalid S3 path. Path must contain only safe characters (alphanumerics, dots, dashes, underscores, slashes).'); + + return true; + } + + // Get helper image + $helperImage = config('constants.coolify.helper_image'); + $latestVersion = getHelperVersion(); + $fullImageName = "{$helperImage}:{$latestVersion}"; + + // Get the database destination network + if ($this->resource->getMorphClass() === ServiceDatabase::class) { + $destinationNetwork = $this->resource->service->destination->network ?? 'coolify'; + } else { + $destinationNetwork = $this->resource->destination->network ?? 'coolify'; + } + + // Generate unique names for this operation + $containerName = "s3-restore-{$this->resourceUuid}"; + $helperTmpPath = '/tmp/'.basename($cleanPath); + $serverTmpPath = "/tmp/s3-restore-{$this->resourceUuid}-".basename($cleanPath); + $containerTmpPath = "/tmp/restore_{$this->resourceUuid}-".basename($cleanPath); + $scriptPath = "/tmp/restore_{$this->resourceUuid}.sh"; + + $escapedServerTmpPath = escapeshellarg($serverTmpPath); + $escapedContainerTmpPath = escapeshellarg($containerTmpPath); + $escapedScriptPath = escapeshellarg($scriptPath); + $escapedHelperContainerPath = escapeshellarg("{$containerName}:{$helperTmpPath}"); + $escapedDatabaseContainerTmpPath = escapeshellarg("{$this->container}:{$containerTmpPath}"); + $escapedDatabaseContainerScriptPath = escapeshellarg("{$this->container}:{$scriptPath}"); + $restoreAndCleanupCommand = escapeshellarg("{$escapedScriptPath} && rm -f {$escapedContainerTmpPath} {$escapedScriptPath}"); + + // Prepare all commands in sequence + $commands = []; + + // 1. Clean up any existing helper container and temp files from previous runs + $commands[] = "docker rm -f {$containerName} 2>/dev/null || true"; + $commands[] = "rm -f {$escapedServerTmpPath} 2>/dev/null || true"; + $commands[] = "docker exec {$this->container} rm -f {$escapedContainerTmpPath} {$escapedScriptPath} 2>/dev/null || true"; + + // 2. Start helper container on the database network + $commands[] = "docker run -d --network {$destinationNetwork} --name {$containerName} {$fullImageName} sleep 3600"; + + // 3. Configure S3 access in helper container + $escapedEndpoint = escapeshellarg($endpoint); + $escapedKey = escapeshellarg($key); + $escapedSecret = escapeshellarg($secret); + $commands[] = "docker exec {$containerName} mc alias set s3temp {$escapedEndpoint} {$escapedKey} {$escapedSecret}"; + + // 4. Check file exists in S3 (bucket and path already validated above) + $escapedS3Source = escapeshellarg("s3temp/{$bucket}/{$cleanPath}"); + $commands[] = "docker exec {$containerName} mc stat {$escapedS3Source}"; + + // 5. Download from S3 to helper container (progress shown by default) + $escapedHelperTmpPath = escapeshellarg($helperTmpPath); + $commands[] = "docker exec {$containerName} mc cp {$escapedS3Source} {$escapedHelperTmpPath}"; + + // 6. Copy from helper to server, then immediately to database container + $commands[] = "docker cp {$escapedHelperContainerPath} {$escapedServerTmpPath}"; + $commands[] = "docker cp {$escapedServerTmpPath} {$escapedDatabaseContainerTmpPath}"; + + // 7. Cleanup helper container and server temp file immediately (no longer needed) + $commands[] = "docker rm -f {$containerName} 2>/dev/null || true"; + $commands[] = "rm -f {$escapedServerTmpPath} 2>/dev/null || true"; + + // 8. Build and execute restore command inside database container + $restoreCommand = $this->buildRestoreCommand($containerTmpPath); + + $restoreCommandBase64 = base64_encode($restoreCommand); + $commands[] = "echo \"{$restoreCommandBase64}\" | base64 -d > {$escapedScriptPath}"; + $commands[] = "chmod +x {$escapedScriptPath}"; + $commands[] = "docker cp {$escapedScriptPath} {$escapedDatabaseContainerScriptPath}"; + + // 9. Execute restore and cleanup temp files immediately after completion + $commands[] = "docker exec {$this->container} sh -c {$restoreAndCleanupCommand}"; + $commands[] = "docker exec {$this->container} sh -c 'echo \"Import finished with exit code $?\"'"; + + // Execute all commands with cleanup event (as safety net for edge cases) + $activity = remote_process($commands, $this->server, ignore_errors: true, callEventOnFinish: 'S3RestoreJobFinished', callEventData: [ + 'containerName' => $containerName, + 'serverTmpPath' => $serverTmpPath, + 'scriptPath' => $scriptPath, + 'containerTmpPath' => $containerTmpPath, + 'container' => $this->container, + 'serverId' => $this->server->id, + ]); + + // Track the activity ID + $this->activityId = $activity->id; + + // Dispatch activity to the monitor and open slide-over + $this->dispatch('activityMonitor', $activity->id); + $this->dispatch('databaserestore'); + $this->dispatch('info', 'Restoring database from S3. Progress will be shown in the activity monitor...'); + } catch (\Throwable $e) { + $this->importRunning = false; + handleError($e, $this); + + return true; + } + + return true; + } + + public function buildRestoreCommand(string $tmpPath): string + { + $escapedTmpPath = escapeshellarg($tmpPath); + $morphClass = $this->resource->getMorphClass(); + + // Handle ServiceDatabase by checking the database type + if ($morphClass === ServiceDatabase::class) { + $dbType = $this->resource->databaseType(); + if (str_contains($dbType, 'mysql')) { + $morphClass = 'mysql'; + } elseif (str_contains($dbType, 'mariadb')) { + $morphClass = 'mariadb'; + } elseif (str_contains($dbType, 'postgres')) { + $morphClass = 'postgresql'; + } elseif (str_contains($dbType, 'mongo')) { + $morphClass = 'mongodb'; + } + } + + switch ($morphClass) { + case StandaloneMariadb::class: + case 'mariadb': + $restoreCommand = $this->mariadbRestoreCommand; + if ($this->dumpAll) { + $restoreCommand .= " && (gunzip -cf {$escapedTmpPath} 2>/dev/null || cat {$escapedTmpPath}) | mariadb -u root -p\$MARIADB_ROOT_PASSWORD \${MARIADB_DATABASE:-default}"; + } else { + $restoreCommand .= " < {$escapedTmpPath}"; + } + break; + case StandaloneMysql::class: + case 'mysql': + $restoreCommand = $this->mysqlRestoreCommand; + if ($this->dumpAll) { + $restoreCommand .= " && (gunzip -cf {$escapedTmpPath} 2>/dev/null || cat {$escapedTmpPath}) | mysql -u root -p\$MYSQL_ROOT_PASSWORD \${MYSQL_DATABASE:-default}"; + } else { + $restoreCommand .= " < {$escapedTmpPath}"; + } + break; + case StandalonePostgresql::class: + case 'postgresql': + $restoreCommand = $this->postgresqlRestoreCommand; + if ($this->dumpAll) { + $restoreCommand .= " && (gunzip -cf {$escapedTmpPath} 2>/dev/null || cat {$escapedTmpPath}) | psql -U \${POSTGRES_USER} -d \${POSTGRES_DB:-\${POSTGRES_USER:-postgres}}"; + } else { + $restoreCommand .= " {$escapedTmpPath}"; + } + break; + case StandaloneMongodb::class: + case 'mongodb': + $restoreCommand = $this->mongodbRestoreCommand.$escapedTmpPath; + break; + default: + $restoreCommand = ''; + } + + return $restoreCommand; + } +} diff --git a/app/Livewire/Project/Database/Keydb/General.php b/app/Livewire/Project/Database/Keydb/General.php index 7c88084995..974803e8d3 100644 --- a/app/Livewire/Project/Database/Keydb/General.php +++ b/app/Livewire/Project/Database/Keydb/General.php @@ -4,11 +4,9 @@ namespace App\Livewire\Project\Database\Keydb; use App\Actions\Database\StartDatabaseProxy; use App\Actions\Database\StopDatabaseProxy; -use App\Helpers\SslHelper; use App\Models\Server; use App\Models\StandaloneKeydb; use App\Support\ValidationPatterns; -use Carbon\Carbon; use Exception; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Support\Facades\Auth; @@ -42,25 +40,21 @@ class General extends Component public ?string $customDockerRunOptions = null; - public ?string $dbUrl = null; - - public ?string $dbUrlPublic = null; - public bool $isLogDrainEnabled = false; - public ?Carbon $certificateValidUntil = null; - - public bool $enable_ssl = false; - - public function getListeners() + public function getListeners(): array { - $userId = Auth::id(); - $teamId = Auth::user()->currentTeam()->id; + $user = Auth::user(); + if (! $user) { + return []; + } + $team = $user->currentTeam(); + if (! $team) { + return []; + } return [ - "echo-private:team.{$teamId},DatabaseProxyStopped" => 'databaseProxyStopped', - "echo-private:user.{$userId},DatabaseStatusChanged" => 'refresh', - "echo-private:team.{$teamId},ServiceChecked" => 'refresh', + "echo-private:team.{$team->id},DatabaseProxyStopped" => 'databaseProxyStopped', ]; } @@ -75,12 +69,6 @@ class General extends Component return; } - - $existingCert = $this->database->sslCertificates()->first(); - - if ($existingCert) { - $this->certificateValidUntil = $existingCert->valid_until; - } } catch (\Throwable $e) { return handleError($e, $this); } @@ -88,7 +76,7 @@ class General extends Component protected function rules(): array { - $baseRules = [ + return [ 'name' => ValidationPatterns::nameRules(), 'description' => ValidationPatterns::descriptionRules(), 'keydbConf' => 'nullable|string', @@ -101,13 +89,8 @@ class General extends Component 'publicPort' => 'nullable|integer|min:1|max:65535', 'publicPortTimeout' => 'nullable|integer|min:1', 'customDockerRunOptions' => 'nullable|string', - 'dbUrl' => 'nullable|string', - 'dbUrlPublic' => 'nullable|string', 'isLogDrainEnabled' => 'nullable|boolean', - 'enable_ssl' => 'boolean', ]; - - return $baseRules; } protected function messages(): array @@ -143,11 +126,7 @@ class General extends Component $this->database->public_port_timeout = $this->publicPortTimeout ?: null; $this->database->custom_docker_run_options = $this->customDockerRunOptions; $this->database->is_log_drain_enabled = $this->isLogDrainEnabled; - $this->database->enable_ssl = $this->enable_ssl; $this->database->save(); - - $this->dbUrl = $this->database->internal_db_url; - $this->dbUrlPublic = $this->database->external_db_url; } else { $this->name = $this->database->name; $this->description = $this->database->description; @@ -160,9 +139,6 @@ class General extends Component $this->publicPortTimeout = $this->database->public_port_timeout; $this->customDockerRunOptions = $this->database->custom_docker_run_options; $this->isLogDrainEnabled = $this->database->is_log_drain_enabled; - $this->enable_ssl = $this->database->enable_ssl; - $this->dbUrl = $this->database->internal_db_url; - $this->dbUrlPublic = $this->database->external_db_url; } } @@ -211,6 +187,7 @@ class General extends Component StopDatabaseProxy::run($this->database); $this->dispatch('success', 'Database is no longer publicly accessible.'); } + $this->dispatch('databaseUpdated'); } catch (\Throwable $e) { $this->isPublic = ! $this->isPublic; $this->syncData(true); @@ -219,9 +196,13 @@ class General extends Component } } - public function databaseProxyStopped() + public function databaseProxyStopped(): void { - $this->syncData(); + $this->database->refresh(); + $this->isPublic = $this->database->is_public; + $this->publicPort = $this->database->public_port; + $this->publicPortTimeout = $this->database->public_port_timeout; + $this->dispatch('databaseUpdated'); } public function submit() @@ -237,6 +218,7 @@ class General extends Component } $this->syncData(true); $this->dispatch('success', 'Database updated.'); + $this->dispatch('databaseUpdated'); } catch (Exception $e) { return handleError($e, $this); } finally { @@ -248,65 +230,6 @@ class General extends Component } } - public function instantSaveSSL() - { - try { - $this->authorize('update', $this->database); - - $this->syncData(true); - $this->dispatch('success', 'SSL configuration updated.'); - } catch (Exception $e) { - return handleError($e, $this); - } - } - - public function regenerateSslCertificate() - { - try { - $this->authorize('update', $this->database); - - $existingCert = $this->database->sslCertificates()->first(); - - if (! $existingCert) { - $this->dispatch('error', 'No existing SSL certificate found for this database.'); - - return; - } - - $caCert = $this->server->sslCertificates() - ->where('is_ca_certificate', true) - ->first(); - - if (! $caCert) { - $this->server->generateCaCertificate(); - $caCert = $this->server->sslCertificates()->where('is_ca_certificate', true)->first(); - } - - if (! $caCert) { - $this->dispatch('error', 'No CA certificate found for this database. Please generate a CA certificate for this server in the server/advanced page.'); - - return; - } - - SslHelper::generateSslCertificate( - commonName: $existingCert->common_name, - subjectAlternativeNames: $existingCert->subject_alternative_names ?? [], - resourceType: $existingCert->resource_type, - resourceId: $existingCert->resource_id, - serverId: $existingCert->server_id, - caCert: $caCert->ssl_certificate, - caKey: $caCert->ssl_private_key, - configurationDir: $existingCert->configuration_dir, - mountPath: $existingCert->mount_path, - isPemKeyFileRequired: true, - ); - - $this->dispatch('success', 'SSL certificates regenerated. Restart database to apply changes.'); - } catch (Exception $e) { - handleError($e, $this); - } - } - public function refresh(): void { $this->database->refresh(); diff --git a/app/Livewire/Project/Database/Keydb/StatusInfo.php b/app/Livewire/Project/Database/Keydb/StatusInfo.php new file mode 100644 index 0000000000..1e87461cd5 --- /dev/null +++ b/app/Livewire/Project/Database/Keydb/StatusInfo.php @@ -0,0 +1,26 @@ +currentTeam()->id; - - return [ - "echo-private:user.{$userId},DatabaseStatusChanged" => 'refresh', - "echo-private:team.{$teamId},ServiceChecked" => 'refresh', - ]; - } - protected function rules(): array { return [ @@ -94,7 +72,6 @@ class General extends Component 'publicPortTimeout' => 'nullable|integer|min:1', 'isLogDrainEnabled' => 'nullable|boolean', 'customDockerRunOptions' => 'nullable', - 'enableSsl' => 'boolean', ]; } @@ -133,7 +110,6 @@ class General extends Component 'publicPort' => 'Public Port', 'publicPortTimeout' => 'Public Port Timeout', 'customDockerRunOptions' => 'Custom Docker Options', - 'enableSsl' => 'Enable SSL', ]; public function mount() @@ -147,12 +123,6 @@ class General extends Component return; } - - $existingCert = $this->database->sslCertificates()->first(); - - if ($existingCert) { - $this->certificateValidUntil = $existingCert->valid_until; - } } catch (Exception $e) { return handleError($e, $this); } @@ -176,11 +146,7 @@ class General extends Component $this->database->public_port_timeout = $this->publicPortTimeout ?: null; $this->database->is_log_drain_enabled = $this->isLogDrainEnabled; $this->database->custom_docker_run_options = $this->customDockerRunOptions; - $this->database->enable_ssl = $this->enableSsl; $this->database->save(); - - $this->db_url = $this->database->internal_db_url; - $this->db_url_public = $this->database->external_db_url; } else { $this->name = $this->database->name; $this->description = $this->database->description; @@ -196,9 +162,6 @@ class General extends Component $this->publicPortTimeout = $this->database->public_port_timeout; $this->isLogDrainEnabled = $this->database->is_log_drain_enabled; $this->customDockerRunOptions = $this->database->custom_docker_run_options; - $this->enableSsl = $this->database->enable_ssl; - $this->db_url = $this->database->internal_db_url; - $this->db_url_public = $this->database->external_db_url; } } @@ -234,6 +197,7 @@ class General extends Component } $this->syncData(true); $this->dispatch('success', 'Database updated.'); + $this->dispatch('databaseUpdated'); } catch (Exception $e) { return handleError($e, $this); } finally { @@ -270,6 +234,7 @@ class General extends Component StopDatabaseProxy::run($this->database); $this->dispatch('success', 'Database is no longer publicly accessible.'); } + $this->dispatch('databaseUpdated'); } catch (\Throwable $e) { $this->isPublic = ! $this->isPublic; $this->syncData(true); @@ -278,63 +243,6 @@ class General extends Component } } - public function instantSaveSSL() - { - try { - $this->authorize('update', $this->database); - - $this->syncData(true); - $this->dispatch('success', 'SSL configuration updated.'); - } catch (Exception $e) { - return handleError($e, $this); - } - } - - public function regenerateSslCertificate() - { - try { - $this->authorize('update', $this->database); - - $existingCert = $this->database->sslCertificates()->first(); - - if (! $existingCert) { - $this->dispatch('error', 'No existing SSL certificate found for this database.'); - - return; - } - - $caCert = $this->server->sslCertificates()->where('is_ca_certificate', true)->first(); - - if (! $caCert) { - $this->server->generateCaCertificate(); - $caCert = $this->server->sslCertificates()->where('is_ca_certificate', true)->first(); - } - - if (! $caCert) { - $this->dispatch('error', 'No CA certificate found for this database. Please generate a CA certificate for this server in the server/advanced page.'); - - return; - } - - SslHelper::generateSslCertificate( - commonName: $existingCert->common_name, - subjectAlternativeNames: $existingCert->subject_alternative_names ?? [], - resourceType: $existingCert->resource_type, - resourceId: $existingCert->resource_id, - serverId: $existingCert->server_id, - caCert: $caCert->ssl_certificate, - caKey: $caCert->ssl_private_key, - configurationDir: $existingCert->configuration_dir, - mountPath: $existingCert->mount_path, - isPemKeyFileRequired: true, - ); - - $this->dispatch('success', 'SSL certificates have been regenerated. Please restart the database for changes to take effect.'); - } catch (Exception $e) { - return handleError($e, $this); - } - } - public function refresh(): void { $this->database->refresh(); diff --git a/app/Livewire/Project/Database/Mariadb/StatusInfo.php b/app/Livewire/Project/Database/Mariadb/StatusInfo.php new file mode 100644 index 0000000000..c6fda37b66 --- /dev/null +++ b/app/Livewire/Project/Database/Mariadb/StatusInfo.php @@ -0,0 +1,21 @@ +currentTeam()->id; - - return [ - "echo-private:user.{$userId},DatabaseStatusChanged" => 'refresh', - "echo-private:team.{$teamId},ServiceChecked" => 'refresh', - ]; - } - protected function rules(): array { return [ @@ -91,8 +67,6 @@ class General extends Component 'publicPortTimeout' => 'nullable|integer|min:1', 'isLogDrainEnabled' => 'nullable|boolean', 'customDockerRunOptions' => 'nullable', - 'enableSsl' => 'boolean', - 'sslMode' => 'nullable|string|in:allow,prefer,require,verify-full', ]; } @@ -112,7 +86,6 @@ class General extends Component 'publicPort.max' => 'The Public Port must not exceed 65535.', 'publicPortTimeout.integer' => 'The Public Port Timeout must be an integer.', 'publicPortTimeout.min' => 'The Public Port Timeout must be at least 1.', - 'sslMode.in' => 'The SSL Mode must be one of: allow, prefer, require, verify-full.', ] ); } @@ -130,8 +103,6 @@ class General extends Component 'publicPort' => 'Public Port', 'publicPortTimeout' => 'Public Port Timeout', 'customDockerRunOptions' => 'Custom Docker Run Options', - 'enableSsl' => 'Enable SSL', - 'sslMode' => 'SSL Mode', ]; public function mount() @@ -145,12 +116,6 @@ class General extends Component return; } - - $existingCert = $this->database->sslCertificates()->first(); - - if ($existingCert) { - $this->certificateValidUntil = $existingCert->valid_until; - } } catch (Exception $e) { return handleError($e, $this); } @@ -173,12 +138,7 @@ class General extends Component $this->database->public_port_timeout = $this->publicPortTimeout ?: null; $this->database->is_log_drain_enabled = $this->isLogDrainEnabled; $this->database->custom_docker_run_options = $this->customDockerRunOptions; - $this->database->enable_ssl = $this->enableSsl; - $this->database->ssl_mode = $this->sslMode; $this->database->save(); - - $this->db_url = $this->database->internal_db_url; - $this->db_url_public = $this->database->external_db_url; } else { $this->name = $this->database->name; $this->description = $this->database->description; @@ -193,10 +153,6 @@ class General extends Component $this->publicPortTimeout = $this->database->public_port_timeout; $this->isLogDrainEnabled = $this->database->is_log_drain_enabled; $this->customDockerRunOptions = $this->database->custom_docker_run_options; - $this->enableSsl = $this->database->enable_ssl; - $this->sslMode = $this->database->ssl_mode; - $this->db_url = $this->database->internal_db_url; - $this->db_url_public = $this->database->external_db_url; } } @@ -235,6 +191,7 @@ class General extends Component } $this->syncData(true); $this->dispatch('success', 'Database updated.'); + $this->dispatch('databaseUpdated'); } catch (Exception $e) { return handleError($e, $this); } finally { @@ -271,6 +228,7 @@ class General extends Component StopDatabaseProxy::run($this->database); $this->dispatch('success', 'Database is no longer publicly accessible.'); } + $this->dispatch('databaseUpdated'); } catch (\Throwable $e) { $this->isPublic = ! $this->isPublic; $this->syncData(true); @@ -279,68 +237,6 @@ class General extends Component } } - public function updatedSslMode() - { - $this->instantSaveSSL(); - } - - public function instantSaveSSL() - { - try { - $this->authorize('update', $this->database); - - $this->syncData(true); - $this->dispatch('success', 'SSL configuration updated.'); - } catch (Exception $e) { - return handleError($e, $this); - } - } - - public function regenerateSslCertificate() - { - try { - $this->authorize('update', $this->database); - - $existingCert = $this->database->sslCertificates()->first(); - - if (! $existingCert) { - $this->dispatch('error', 'No existing SSL certificate found for this database.'); - - return; - } - - $caCert = $this->server->sslCertificates()->where('is_ca_certificate', true)->first(); - - if (! $caCert) { - $this->server->generateCaCertificate(); - $caCert = $this->server->sslCertificates()->where('is_ca_certificate', true)->first(); - } - - if (! $caCert) { - $this->dispatch('error', 'No CA certificate found for this database. Please generate a CA certificate for this server in the server/advanced page.'); - - return; - } - - SslHelper::generateSslCertificate( - commonName: $existingCert->common_name, - subjectAlternativeNames: $existingCert->subject_alternative_names ?? [], - resourceType: $existingCert->resource_type, - resourceId: $existingCert->resource_id, - serverId: $existingCert->server_id, - caCert: $caCert->ssl_certificate, - caKey: $caCert->ssl_private_key, - configurationDir: $existingCert->configuration_dir, - mountPath: $existingCert->mount_path, - isPemKeyFileRequired: true, - ); - - $this->dispatch('success', 'SSL certificates have been regenerated. Please restart the database for changes to take effect.'); - } catch (Exception $e) { - return handleError($e, $this); - } - } - public function refresh(): void { $this->database->refresh(); diff --git a/app/Livewire/Project/Database/Mongodb/StatusInfo.php b/app/Livewire/Project/Database/Mongodb/StatusInfo.php new file mode 100644 index 0000000000..a92a682c95 --- /dev/null +++ b/app/Livewire/Project/Database/Mongodb/StatusInfo.php @@ -0,0 +1,51 @@ + ['title' => 'Allow insecure connections', 'label' => 'allow (insecure)'], + 'prefer' => ['title' => 'Prefer secure connections', 'label' => 'prefer (secure)'], + 'require' => ['title' => 'Require secure connections', 'label' => 'require (secure)'], + 'verify-full' => ['title' => 'Verify full certificate', 'label' => 'verify-full (secure)'], + ]; + } + + protected function sslModeHelper(): string + { + return 'Choose the SSL verification mode for MongoDB connections'; + } + + protected function afterRefresh(): void + { + $this->sslMode = $this->database->ssl_mode; + } + + protected function applyExtraSslAttributes(): void + { + $this->database->ssl_mode = $this->sslMode; + } + + public function updatedSslMode(): void + { + $this->instantSaveSSL(); + } +} diff --git a/app/Livewire/Project/Database/Mysql/General.php b/app/Livewire/Project/Database/Mysql/General.php index 34726bd0ae..6b88d735de 100644 --- a/app/Livewire/Project/Database/Mysql/General.php +++ b/app/Livewire/Project/Database/Mysql/General.php @@ -4,14 +4,11 @@ namespace App\Livewire\Project\Database\Mysql; use App\Actions\Database\StartDatabaseProxy; use App\Actions\Database\StopDatabaseProxy; -use App\Helpers\SslHelper; use App\Models\Server; use App\Models\StandaloneMysql; use App\Support\ValidationPatterns; -use Carbon\Carbon; use Exception; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; -use Illuminate\Support\Facades\Auth; use Livewire\Component; class General extends Component @@ -50,27 +47,6 @@ class General extends Component public ?string $customDockerRunOptions = null; - public bool $enableSsl = false; - - public ?string $sslMode = null; - - public ?string $db_url = null; - - public ?string $db_url_public = null; - - public ?Carbon $certificateValidUntil = null; - - public function getListeners() - { - $userId = Auth::id(); - $teamId = Auth::user()->currentTeam()->id; - - return [ - "echo-private:user.{$userId},DatabaseStatusChanged" => 'refresh', - "echo-private:team.{$teamId},ServiceChecked" => 'refresh', - ]; - } - protected function rules(): array { return [ @@ -96,8 +72,6 @@ class General extends Component 'publicPortTimeout' => 'nullable|integer|min:1', 'isLogDrainEnabled' => 'nullable|boolean', 'customDockerRunOptions' => 'nullable', - 'enableSsl' => 'boolean', - 'sslMode' => 'nullable|string|in:PREFERRED,REQUIRED,VERIFY_CA,VERIFY_IDENTITY', ]; } @@ -118,7 +92,6 @@ class General extends Component 'publicPort.max' => 'The Public Port must not exceed 65535.', 'publicPortTimeout.integer' => 'The Public Port Timeout must be an integer.', 'publicPortTimeout.min' => 'The Public Port Timeout must be at least 1.', - 'sslMode.in' => 'The SSL Mode must be one of: PREFERRED, REQUIRED, VERIFY_CA, VERIFY_IDENTITY.', ] ); } @@ -137,8 +110,6 @@ class General extends Component 'publicPort' => 'Public Port', 'publicPortTimeout' => 'Public Port Timeout', 'customDockerRunOptions' => 'Custom Docker Run Options', - 'enableSsl' => 'Enable SSL', - 'sslMode' => 'SSL Mode', ]; public function mount() @@ -152,12 +123,6 @@ class General extends Component return; } - - $existingCert = $this->database->sslCertificates()->first(); - - if ($existingCert) { - $this->certificateValidUntil = $existingCert->valid_until; - } } catch (Exception $e) { return handleError($e, $this); } @@ -181,12 +146,7 @@ class General extends Component $this->database->public_port_timeout = $this->publicPortTimeout ?: null; $this->database->is_log_drain_enabled = $this->isLogDrainEnabled; $this->database->custom_docker_run_options = $this->customDockerRunOptions; - $this->database->enable_ssl = $this->enableSsl; - $this->database->ssl_mode = $this->sslMode; $this->database->save(); - - $this->db_url = $this->database->internal_db_url; - $this->db_url_public = $this->database->external_db_url; } else { $this->name = $this->database->name; $this->description = $this->database->description; @@ -202,10 +162,6 @@ class General extends Component $this->publicPortTimeout = $this->database->public_port_timeout; $this->isLogDrainEnabled = $this->database->is_log_drain_enabled; $this->customDockerRunOptions = $this->database->custom_docker_run_options; - $this->enableSsl = $this->database->enable_ssl; - $this->sslMode = $this->database->ssl_mode; - $this->db_url = $this->database->internal_db_url; - $this->db_url_public = $this->database->external_db_url; } } @@ -241,6 +197,7 @@ class General extends Component } $this->syncData(true); $this->dispatch('success', 'Database updated.'); + $this->dispatch('databaseUpdated'); } catch (Exception $e) { return handleError($e, $this); } finally { @@ -277,6 +234,7 @@ class General extends Component StopDatabaseProxy::run($this->database); $this->dispatch('success', 'Database is no longer publicly accessible.'); } + $this->dispatch('databaseUpdated'); } catch (\Throwable $e) { $this->isPublic = ! $this->isPublic; $this->syncData(true); @@ -285,68 +243,6 @@ class General extends Component } } - public function updatedSslMode() - { - $this->instantSaveSSL(); - } - - public function instantSaveSSL() - { - try { - $this->authorize('update', $this->database); - - $this->syncData(true); - $this->dispatch('success', 'SSL configuration updated.'); - } catch (Exception $e) { - return handleError($e, $this); - } - } - - public function regenerateSslCertificate() - { - try { - $this->authorize('update', $this->database); - - $existingCert = $this->database->sslCertificates()->first(); - - if (! $existingCert) { - $this->dispatch('error', 'No existing SSL certificate found for this database.'); - - return; - } - - $caCert = $this->server->sslCertificates()->where('is_ca_certificate', true)->first(); - - if (! $caCert) { - $this->server->generateCaCertificate(); - $caCert = $this->server->sslCertificates()->where('is_ca_certificate', true)->first(); - } - - if (! $caCert) { - $this->dispatch('error', 'No CA certificate found for this database. Please generate a CA certificate for this server in the server/advanced page.'); - - return; - } - - SslHelper::generateSslCertificate( - commonName: $existingCert->common_name, - subjectAlternativeNames: $existingCert->subject_alternative_names ?? [], - resourceType: $existingCert->resource_type, - resourceId: $existingCert->resource_id, - serverId: $existingCert->server_id, - caCert: $caCert->ssl_certificate, - caKey: $caCert->ssl_private_key, - configurationDir: $existingCert->configuration_dir, - mountPath: $existingCert->mount_path, - isPemKeyFileRequired: true, - ); - - $this->dispatch('success', 'SSL certificates have been regenerated. Please restart the database for changes to take effect.'); - } catch (Exception $e) { - return handleError($e, $this); - } - } - public function refresh(): void { $this->database->refresh(); diff --git a/app/Livewire/Project/Database/Mysql/StatusInfo.php b/app/Livewire/Project/Database/Mysql/StatusInfo.php new file mode 100644 index 0000000000..5fbbc15831 --- /dev/null +++ b/app/Livewire/Project/Database/Mysql/StatusInfo.php @@ -0,0 +1,51 @@ + ['title' => 'Prefer secure connections', 'label' => 'Prefer (secure)'], + 'REQUIRED' => ['title' => 'Require secure connections', 'label' => 'Require (secure)'], + 'VERIFY_CA' => ['title' => 'Verify CA certificate', 'label' => 'Verify CA (secure)'], + 'VERIFY_IDENTITY' => ['title' => 'Verify full certificate', 'label' => 'Verify Full (secure)'], + ]; + } + + protected function sslModeHelper(): string + { + return 'Choose the SSL verification mode for MySQL connections'; + } + + protected function afterRefresh(): void + { + $this->sslMode = $this->database->ssl_mode; + } + + protected function applyExtraSslAttributes(): void + { + $this->database->ssl_mode = $this->sslMode; + } + + public function updatedSslMode(): void + { + $this->instantSaveSSL(); + } +} diff --git a/app/Livewire/Project/Database/Postgresql/General.php b/app/Livewire/Project/Database/Postgresql/General.php index b5fb854838..4e89e8b62f 100644 --- a/app/Livewire/Project/Database/Postgresql/General.php +++ b/app/Livewire/Project/Database/Postgresql/General.php @@ -4,14 +4,11 @@ namespace App\Livewire\Project\Database\Postgresql; use App\Actions\Database\StartDatabaseProxy; use App\Actions\Database\StopDatabaseProxy; -use App\Helpers\SslHelper; use App\Models\Server; use App\Models\StandalonePostgresql; use App\Support\ValidationPatterns; -use Carbon\Carbon; use Exception; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; -use Illuminate\Support\Facades\Auth; use Livewire\Component; class General extends Component @@ -54,32 +51,14 @@ class General extends Component public ?string $customDockerRunOptions = null; - public bool $enableSsl = false; - - public ?string $sslMode = null; - public string $new_filename; public string $new_content; - public ?string $db_url = null; - - public ?string $db_url_public = null; - - public ?Carbon $certificateValidUntil = null; - - public function getListeners() - { - $userId = Auth::id(); - $teamId = Auth::user()->currentTeam()->id; - - return [ - "echo-private:user.{$userId},DatabaseStatusChanged" => 'refresh', - "echo-private:team.{$teamId},ServiceChecked" => 'refresh', - 'save_init_script', - 'delete_init_script', - ]; - } + protected $listeners = [ + 'save_init_script', + 'delete_init_script', + ]; protected function rules(): array { @@ -106,8 +85,6 @@ class General extends Component 'publicPortTimeout' => 'nullable|integer|min:1', 'isLogDrainEnabled' => 'nullable|boolean', 'customDockerRunOptions' => 'nullable', - 'enableSsl' => 'boolean', - 'sslMode' => 'nullable|string|in:allow,prefer,require,verify-ca,verify-full', ]; } @@ -127,7 +104,6 @@ class General extends Component 'publicPort.max' => 'The Public Port must not exceed 65535.', 'publicPortTimeout.integer' => 'The Public Port Timeout must be an integer.', 'publicPortTimeout.min' => 'The Public Port Timeout must be at least 1.', - 'sslMode.in' => 'The SSL Mode must be one of: allow, prefer, require, verify-ca, verify-full.', ] ); } @@ -148,8 +124,6 @@ class General extends Component 'publicPort' => 'Public Port', 'publicPortTimeout' => 'Public Port Timeout', 'customDockerRunOptions' => 'Custom Docker Run Options', - 'enableSsl' => 'Enable SSL', - 'sslMode' => 'SSL Mode', ]; public function mount() @@ -163,12 +137,6 @@ class General extends Component return; } - - $existingCert = $this->database->sslCertificates()->first(); - - if ($existingCert) { - $this->certificateValidUntil = $existingCert->valid_until; - } } catch (Exception $e) { return handleError($e, $this); } @@ -194,12 +162,7 @@ class General extends Component $this->database->public_port_timeout = $this->publicPortTimeout ?: null; $this->database->is_log_drain_enabled = $this->isLogDrainEnabled; $this->database->custom_docker_run_options = $this->customDockerRunOptions; - $this->database->enable_ssl = $this->enableSsl; - $this->database->ssl_mode = $this->sslMode; $this->database->save(); - - $this->db_url = $this->database->internal_db_url; - $this->db_url_public = $this->database->external_db_url; } else { $this->name = $this->database->name; $this->description = $this->database->description; @@ -217,10 +180,6 @@ class General extends Component $this->publicPortTimeout = $this->database->public_port_timeout; $this->isLogDrainEnabled = $this->database->is_log_drain_enabled; $this->customDockerRunOptions = $this->database->custom_docker_run_options; - $this->enableSsl = $this->database->enable_ssl; - $this->sslMode = $this->database->ssl_mode; - $this->db_url = $this->database->internal_db_url; - $this->db_url_public = $this->database->external_db_url; } } @@ -243,68 +202,6 @@ class General extends Component } } - public function updatedSslMode() - { - $this->instantSaveSSL(); - } - - public function instantSaveSSL() - { - try { - $this->authorize('update', $this->database); - - $this->syncData(true); - $this->dispatch('success', 'SSL configuration updated.'); - } catch (Exception $e) { - return handleError($e, $this); - } - } - - public function regenerateSslCertificate() - { - try { - $this->authorize('update', $this->database); - - $existingCert = $this->database->sslCertificates()->first(); - - if (! $existingCert) { - $this->dispatch('error', 'No existing SSL certificate found for this database.'); - - return; - } - - $caCert = $this->server->sslCertificates()->where('is_ca_certificate', true)->first(); - - if (! $caCert) { - $this->server->generateCaCertificate(); - $caCert = $this->server->sslCertificates()->where('is_ca_certificate', true)->first(); - } - - if (! $caCert) { - $this->dispatch('error', 'No CA certificate found for this database. Please generate a CA certificate for this server in the server/advanced page.'); - - return; - } - - SslHelper::generateSslCertificate( - commonName: $existingCert->common_name, - subjectAlternativeNames: $existingCert->subject_alternative_names ?? [], - resourceType: $existingCert->resource_type, - resourceId: $existingCert->resource_id, - serverId: $existingCert->server_id, - caCert: $caCert->ssl_certificate, - caKey: $caCert->ssl_private_key, - configurationDir: $existingCert->configuration_dir, - mountPath: $existingCert->mount_path, - isPemKeyFileRequired: true, - ); - - $this->dispatch('success', 'SSL certificates have been regenerated. Please restart the database for changes to take effect.'); - } catch (Exception $e) { - return handleError($e, $this); - } - } - public function instantSave() { try { @@ -330,6 +227,7 @@ class General extends Component StopDatabaseProxy::run($this->database); $this->dispatch('success', 'Database is no longer publicly accessible.'); } + $this->dispatch('databaseUpdated'); } catch (\Throwable $e) { $this->isPublic = ! $this->isPublic; $this->syncData(true); @@ -493,6 +391,7 @@ class General extends Component } $this->syncData(true); $this->dispatch('success', 'Database updated.'); + $this->dispatch('databaseUpdated'); } catch (Exception $e) { return handleError($e, $this); } finally { diff --git a/app/Livewire/Project/Database/Postgresql/StatusInfo.php b/app/Livewire/Project/Database/Postgresql/StatusInfo.php new file mode 100644 index 0000000000..cc27b61bb1 --- /dev/null +++ b/app/Livewire/Project/Database/Postgresql/StatusInfo.php @@ -0,0 +1,52 @@ + ['title' => 'Allow insecure connections', 'label' => 'allow (insecure)'], + 'prefer' => ['title' => 'Prefer secure connections', 'label' => 'prefer (secure)'], + 'require' => ['title' => 'Require secure connections', 'label' => 'require (secure)'], + 'verify-ca' => ['title' => 'Verify CA certificate', 'label' => 'verify-ca (secure)'], + 'verify-full' => ['title' => 'Verify full certificate', 'label' => 'verify-full (secure)'], + ]; + } + + protected function sslModeHelper(): string + { + return 'Choose the SSL verification mode for PostgreSQL connections'; + } + + protected function afterRefresh(): void + { + $this->sslMode = $this->database->ssl_mode; + } + + protected function applyExtraSslAttributes(): void + { + $this->database->ssl_mode = $this->sslMode; + } + + public function updatedSslMode(): void + { + $this->instantSaveSSL(); + } +} diff --git a/app/Livewire/Project/Database/Redis/General.php b/app/Livewire/Project/Database/Redis/General.php index c3cc43972c..aff7b7afa0 100644 --- a/app/Livewire/Project/Database/Redis/General.php +++ b/app/Livewire/Project/Database/Redis/General.php @@ -4,14 +4,11 @@ namespace App\Livewire\Project\Database\Redis; use App\Actions\Database\StartDatabaseProxy; use App\Actions\Database\StopDatabaseProxy; -use App\Helpers\SslHelper; use App\Models\Server; use App\Models\StandaloneRedis; use App\Support\ValidationPatterns; -use Carbon\Carbon; use Exception; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; -use Illuminate\Support\Facades\Auth; use Livewire\Component; class General extends Component @@ -48,25 +45,9 @@ class General extends Component public string $redisVersion; - public ?string $dbUrl = null; - - public ?string $dbUrlPublic = null; - - public bool $enableSsl = false; - - public ?Carbon $certificateValidUntil = null; - - public function getListeners() - { - $userId = Auth::id(); - $teamId = Auth::user()->currentTeam()->id; - - return [ - "echo-private:user.{$userId},DatabaseStatusChanged" => 'refresh', - "echo-private:team.{$teamId},ServiceChecked" => 'refresh', - 'envsUpdated' => 'refresh', - ]; - } + protected $listeners = [ + 'envsUpdated' => 'refresh', + ]; protected function rules(): array { @@ -87,7 +68,6 @@ class General extends Component 'redisPassword' => ValidationPatterns::databasePasswordRules( enforcePattern: $this->redisPassword !== $this->database->redis_password, ), - 'enableSsl' => 'boolean', ]; } @@ -122,7 +102,6 @@ class General extends Component 'customDockerRunOptions' => 'Custom Docker Options', 'redisUsername' => 'Redis Username', 'redisPassword' => 'Redis Password', - 'enableSsl' => 'Enable SSL', ]; public function mount() @@ -136,12 +115,6 @@ class General extends Component return; } - - $existingCert = $this->database->sslCertificates()->first(); - - if ($existingCert) { - $this->certificateValidUntil = $existingCert->valid_until; - } } catch (\Throwable $e) { return handleError($e, $this); } @@ -161,11 +134,7 @@ class General extends Component $this->database->public_port_timeout = $this->publicPortTimeout ?: null; $this->database->is_log_drain_enabled = $this->isLogDrainEnabled; $this->database->custom_docker_run_options = $this->customDockerRunOptions; - $this->database->enable_ssl = $this->enableSsl; $this->database->save(); - - $this->dbUrl = $this->database->internal_db_url; - $this->dbUrlPublic = $this->database->external_db_url; } else { $this->name = $this->database->name; $this->description = $this->database->description; @@ -177,9 +146,6 @@ class General extends Component $this->publicPortTimeout = $this->database->public_port_timeout; $this->isLogDrainEnabled = $this->database->is_log_drain_enabled; $this->customDockerRunOptions = $this->database->custom_docker_run_options; - $this->enableSsl = $this->database->enable_ssl; - $this->dbUrl = $this->database->internal_db_url; - $this->dbUrlPublic = $this->database->external_db_url; $this->redisVersion = $this->database->getRedisVersion(); $this->redisUsername = $this->database->redis_username; $this->redisPassword = $this->database->redis_password; @@ -227,6 +193,7 @@ class General extends Component ); $this->dispatch('success', 'Database updated.'); + $this->dispatch('databaseUpdated'); } catch (Exception $e) { return handleError($e, $this); } finally { @@ -259,6 +226,7 @@ class General extends Component StopDatabaseProxy::run($this->database); $this->dispatch('success', 'Database is no longer publicly accessible.'); } + $this->dispatch('databaseUpdated'); } catch (\Throwable $e) { $this->isPublic = ! $this->isPublic; $this->syncData(true); @@ -267,63 +235,6 @@ class General extends Component } } - public function instantSaveSSL() - { - try { - $this->authorize('update', $this->database); - - $this->syncData(true); - $this->dispatch('success', 'SSL configuration updated.'); - } catch (Exception $e) { - return handleError($e, $this); - } - } - - public function regenerateSslCertificate() - { - try { - $this->authorize('update', $this->database); - - $existingCert = $this->database->sslCertificates()->first(); - - if (! $existingCert) { - $this->dispatch('error', 'No existing SSL certificate found for this database.'); - - return; - } - - $caCert = $this->server->sslCertificates()->where('is_ca_certificate', true)->first(); - - if (! $caCert) { - $this->server->generateCaCertificate(); - $caCert = $this->server->sslCertificates()->where('is_ca_certificate', true)->first(); - } - - if (! $caCert) { - $this->dispatch('error', 'No CA certificate found for this database. Please generate a CA certificate for this server in the server/advanced page.'); - - return; - } - - SslHelper::generateSslCertificate( - commonName: $existingCert->common_name, - subjectAlternativeNames: $existingCert->subject_alternative_names ?? [], - resourceType: $existingCert->resource_type, - resourceId: $existingCert->resource_id, - serverId: $existingCert->server_id, - caCert: $caCert->ssl_certificate, - caKey: $caCert->ssl_private_key, - configurationDir: $existingCert->configuration_dir, - mountPath: $existingCert->mount_path, - isPemKeyFileRequired: true, - ); - - $this->dispatch('success', 'SSL certificates regenerated. Restart database to apply changes.'); - } catch (Exception $e) { - handleError($e, $this); - } - } - public function refresh(): void { $this->database->refresh(); diff --git a/app/Livewire/Project/Database/Redis/StatusInfo.php b/app/Livewire/Project/Database/Redis/StatusInfo.php new file mode 100644 index 0000000000..2e784e2c09 --- /dev/null +++ b/app/Livewire/Project/Database/Redis/StatusInfo.php @@ -0,0 +1,21 @@ +parameters = get_route_parameters(); $this->query = request()->query(); $this->repositories = $this->branches = collect(); - $this->github_apps = GithubApp::where('team_id', currentTeam()->id) + $this->github_apps = GithubApp::ownedByCurrentTeam() ->where('is_public', false) ->whereNotNull('app_id') ->get(); @@ -108,7 +108,7 @@ class GithubPrivateRepository extends Component $this->total_branches_count = 0; $this->page = 1; $this->selected_github_app_id = $github_app_id; - $this->github_app = GithubApp::where('team_id', currentTeam()->id) + $this->github_app = GithubApp::ownedByCurrentTeam() ->where('is_public', false) ->whereNotNull('app_id') ->findOrFail($github_app_id); diff --git a/app/Livewire/Project/Service/Configuration.php b/app/Livewire/Project/Service/Configuration.php index 2d69ceb12b..ac2b39bb86 100644 --- a/app/Livewire/Project/Service/Configuration.php +++ b/app/Livewire/Project/Service/Configuration.php @@ -4,7 +4,6 @@ namespace App\Livewire\Project\Service; use App\Models\Service; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; -use Illuminate\Support\Facades\Auth; use Livewire\Component; class Configuration extends Component @@ -27,16 +26,10 @@ class Configuration extends Component public array $parameters; - public function getListeners() - { - $teamId = Auth::user()->currentTeam()->id; - - return [ - "echo-private:team.{$teamId},ServiceChecked" => 'serviceChecked', - 'refreshServices' => 'refreshServices', - 'refresh' => 'refreshServices', - ]; - } + protected $listeners = [ + 'refreshServices' => 'refreshServices', + 'refresh' => 'refreshServices', + ]; public function render() { @@ -105,18 +98,4 @@ class Configuration extends Component return handleError($e, $this); } } - - public function serviceChecked() - { - try { - $this->service->applications->each(function ($application) { - $application->refresh(); - }); - $this->service->databases->each(function ($database) { - $database->refresh(); - }); - } catch (\Exception $e) { - return handleError($e, $this); - } - } } diff --git a/app/Livewire/Project/Service/ResourceCard.php b/app/Livewire/Project/Service/ResourceCard.php new file mode 100644 index 0000000000..fd27f60c30 --- /dev/null +++ b/app/Livewire/Project/Service/ResourceCard.php @@ -0,0 +1,66 @@ +currentTeam(); + if (! $team) { + return []; + } + + return [ + "echo-private:team.{$team->id},ServiceChecked" => 'refreshResource', + ]; + } + + public function refreshResource(): void + { + $this->resource->refresh(); + } + + public function restart(): void + { + try { + $this->authorize('update', $this->service); + $this->resource->restart(); + $message = $this->resource instanceof ServiceApplication + ? 'Service application restarted successfully.' + : 'Service database restarted successfully.'; + $this->dispatch('success', $message); + } catch (\Throwable $e) { + handleError($e, $this); + } + } + + public function render(): View + { + return view('livewire.project.service.resource-card', [ + 'isApplication' => $this->resource instanceof ServiceApplication, + 'isDatabase' => $this->resource instanceof ServiceDatabase, + ]); + } +} diff --git a/app/Livewire/Project/Shared/Destination.php b/app/Livewire/Project/Shared/Destination.php index d9e5600742..715ce82a73 100644 --- a/app/Livewire/Project/Shared/Destination.php +++ b/app/Livewire/Project/Shared/Destination.php @@ -112,18 +112,22 @@ class Destination extends Component { try { $server = Server::ownedByCurrentTeam()->findOrFail($server_id); - $network = StandaloneDocker::ownedByCurrentTeam()->findOrFail($network_id); + $network = StandaloneDocker::ownedByCurrentTeam()->where('server_id', $server->id)->findOrFail($network_id); $this->authorize('update', $this->resource); - $main_destination = $this->resource->destination; - $this->resource->update([ - 'destination_id' => $network->id, - 'destination_type' => StandaloneDocker::class, - ]); - $this->resource->additional_networks()->detach($network->id, ['server_id' => $server->id]); - $this->resource->additional_networks()->attach($main_destination->id, ['server_id' => $main_destination->server->id]); - $this->refreshServers(); + $this->resource->getConnection()->transaction(function () use ($network, $server) { + $main_destination = $this->resource->destination; + $this->resource->update([ + 'destination_id' => $network->id, + 'destination_type' => StandaloneDocker::class, + ]); + $this->resource->additional_networks() + ->wherePivot('server_id', $server->id) + ->detach($network->id); + $this->resource->additional_networks()->attach($main_destination->id, ['server_id' => $main_destination->server->id]); + }); $this->resource->refresh(); + $this->refreshServers(); } catch (\Exception $e) { return handleError($e, $this); } @@ -140,7 +144,7 @@ class Destination extends Component { try { $server = Server::ownedByCurrentTeam()->findOrFail($server_id); - $network = StandaloneDocker::ownedByCurrentTeam()->findOrFail($network_id); + $network = StandaloneDocker::ownedByCurrentTeam()->where('server_id', $server->id)->findOrFail($network_id); $this->authorize('update', $this->resource); $this->resource->additional_networks()->attach($network->id, ['server_id' => $server->id]); @@ -164,7 +168,9 @@ class Destination extends Component } $server = Server::ownedByCurrentTeam()->findOrFail($server_id); StopApplicationOneServer::run($this->resource, $server); - $this->resource->additional_networks()->detach($network_id, ['server_id' => $server_id]); + $this->resource->additional_networks() + ->wherePivot('server_id', $server_id) + ->detach($network_id); $this->loadData(); $this->dispatch('refresh'); ApplicationStatusChanged::dispatch(data_get($this->resource, 'environment.project.team.id')); diff --git a/app/Livewire/Server/Sentinel.php b/app/Livewire/Server/Sentinel.php index a4b35891b2..06aebd8f82 100644 --- a/app/Livewire/Server/Sentinel.php +++ b/app/Livewire/Server/Sentinel.php @@ -93,7 +93,9 @@ class Sentinel extends Component { if ($event['serverUuid'] === $this->server->uuid) { $this->server->refresh(); - $this->syncData(); + // Only refresh display-only state; never re-sync text-input properties + // (would clobber any unsaved typing — see coolify#6062 / #6354 / #9695). + $this->sentinelUpdatedAt = $this->server->sentinel_updated_at; $this->dispatch('success', 'Sentinel has been restarted successfully.'); } } diff --git a/app/Livewire/Server/Show.php b/app/Livewire/Server/Show.php index 3e05d9306c..d7339dcdba 100644 --- a/app/Livewire/Server/Show.php +++ b/app/Livewire/Server/Show.php @@ -277,7 +277,9 @@ class Show extends Component // Only refresh if the event is for this server if (isset($event['serverUuid']) && $event['serverUuid'] === $this->server->uuid) { $this->server->refresh(); - $this->syncData(); + // Only refresh display-only state; never re-sync text-input properties + // (would clobber any unsaved typing — see coolify#6062 / #6354 / #9695). + $this->sentinelUpdatedAt = $this->server->sentinel_updated_at; $this->dispatch('success', 'Sentinel has been restarted successfully.'); } } @@ -457,12 +459,15 @@ class Show extends Component return; } - // Refresh server data + // Refresh server data and only the display-only state that validation produces. + // Never re-sync text-input properties via syncData() — would clobber any + // unsaved typing (see coolify#6062 / #6354 / #9695). $this->server->refresh(); - $this->syncData(); - - // Update validation state + $this->server->settings->refresh(); $this->isValidating = $this->server->is_validating ?? false; + $this->validationLogs = $this->server->validation_logs; + $this->isReachable = $this->server->settings->is_reachable; + $this->isUsable = $this->server->settings->is_usable; // Reload Hetzner tokens in case the linking section should now be shown $this->loadHetznerTokens(); diff --git a/app/Livewire/SettingsDropdown.php b/app/Livewire/SettingsDropdown.php index 7afa763df7..cd41197cb6 100644 --- a/app/Livewire/SettingsDropdown.php +++ b/app/Livewire/SettingsDropdown.php @@ -11,6 +11,8 @@ class SettingsDropdown extends Component { public $showWhatsNewModal = false; + public string $trigger = 'preferences'; + public function getUnreadCountProperty() { return Auth::user()->getUnreadChangelogCount(); diff --git a/app/Livewire/Source/Github/Change.php b/app/Livewire/Source/Github/Change.php index cc9ceea8ad..1470b95dbf 100644 --- a/app/Livewire/Source/Github/Change.php +++ b/app/Livewire/Source/Github/Change.php @@ -21,6 +21,10 @@ class Change extends Component public string $webhook_endpoint = ''; + public string $custom_webhook_endpoint = ''; + + public bool $use_custom_webhook_endpoint = false; + public ?string $ipv4 = null; public ?string $ipv6 = null; @@ -76,6 +80,8 @@ class Change extends Component public string $manifestState = ''; + public string $activeTab = 'general'; + protected function rules(): array { return [ @@ -95,6 +101,9 @@ class Change extends Component 'metadata' => 'nullable|string', 'pullRequests' => 'nullable|string', 'privateKeyId' => 'nullable|int', + 'webhook_endpoint' => ['required', 'string', 'url'], + 'custom_webhook_endpoint' => ['nullable', 'string', 'url'], + 'use_custom_webhook_endpoint' => ['required', 'bool'], ]; } @@ -263,10 +272,18 @@ class Change extends Component } } $this->parameters = get_route_parameters(); + $routeName = request()->route()?->getName(); + if ($routeName === 'source.github.permissions') { + $this->activeTab = 'permissions'; + } elseif ($routeName === 'source.github.resources') { + $this->activeTab = 'resources'; + } else { + $this->activeTab = 'general'; + } if (isCloud() && ! isDev()) { $this->webhook_endpoint = config('app.url'); } else { - $this->webhook_endpoint = $this->fqdn ?? $this->ipv4 ?? ''; + $this->webhook_endpoint = $this->fqdn ?? $this->ipv4 ?? $this->ipv6 ?? config('app.url') ?? ''; $this->is_system_wide = $this->github_app->is_system_wide; } } catch (\Throwable $e) { diff --git a/app/Models/Application.php b/app/Models/Application.php index 003152717a..c14131e6b7 100644 --- a/app/Models/Application.php +++ b/app/Models/Application.php @@ -1187,17 +1187,20 @@ class Application extends BaseModel $currentSnapshot = $this->deploymentConfigurationSnapshot(); $lastDeployment = $this->get_last_successful_deployment(); - if ($lastDeployment?->configuration_snapshot) { - return app(ConfigurationDiffer::class)->diff($lastDeployment->configuration_snapshot, $currentSnapshot); + $previousSnapshot = $lastDeployment?->configuration_snapshot; + + if (! $previousSnapshot) { + $oldConfigHash = data_get($this, 'config_hash'); + $hasLegacyChange = $oldConfigHash === null || $oldConfigHash !== $this->legacyConfigurationHash(); + + if (! $hasLegacyChange) { + return ConfigurationDiff::unchanged(); + } + + $previousSnapshot = []; } - $oldConfigHash = data_get($this, 'config_hash'); - - if ($oldConfigHash === null) { - return ConfigurationDiff::legacy(true); - } - - return ConfigurationDiff::legacy($oldConfigHash !== $this->legacyConfigurationHash()); + return app(ConfigurationDiffer::class)->diff($previousSnapshot, $currentSnapshot); } public function hasPendingDeploymentConfigurationChanges(): bool diff --git a/app/Models/S3Storage.php b/app/Models/S3Storage.php index 3f6ee51ccc..90232a151b 100644 --- a/app/Models/S3Storage.php +++ b/app/Models/S3Storage.php @@ -14,6 +14,10 @@ class S3Storage extends BaseModel { use HasFactory, HasSafeStringAttribute; + private const CONNECTION_TIMEOUT_SECONDS = 15; + + private const REQUEST_TIMEOUT_SECONDS = 15; + protected $fillable = [ 'name', 'description', @@ -157,6 +161,10 @@ class S3Storage extends BaseModel 'bucket' => $this['bucket'], 'endpoint' => $this['endpoint'], 'use_path_style_endpoint' => true, + 'http' => [ + 'connect_timeout' => self::CONNECTION_TIMEOUT_SECONDS, + 'timeout' => self::REQUEST_TIMEOUT_SECONDS, + ], ]); // Test the connection by listing files with ListObjectsV2 (S3) $disk->files(); @@ -164,11 +172,12 @@ class S3Storage extends BaseModel $this->unusable_email_sent = false; $this->is_usable = true; } catch (\Throwable $e) { + $exception = $this->toUserFriendlyConnectionException($e); $this->is_usable = false; if ($this->unusable_email_sent === false && is_transactional_emails_enabled()) { $mail = new MailMessage; $mail->subject('Coolify: S3 Storage Connection Error'); - $mail->view('emails.s3-connection-error', ['name' => $this->name, 'reason' => $e->getMessage(), 'url' => route('storage.show', ['storage_uuid' => $this->uuid])]); + $mail->view('emails.s3-connection-error', ['name' => $this->name, 'reason' => $exception->getMessage(), 'url' => route('storage.show', ['storage_uuid' => $this->uuid])]); // Load the team with its members and their roles explicitly $team = $this->team()->with(['members' => function ($query) { @@ -183,11 +192,25 @@ class S3Storage extends BaseModel $this->unusable_email_sent = true; } - throw $e; + throw $exception; } finally { if ($shouldSave) { $this->save(); } } } + + private function toUserFriendlyConnectionException(\Throwable $exception): \Throwable + { + $message = str($exception->getMessage())->lower(); + + if ($message->contains(['timed out', 'timeout', 'connection refused', 'could not resolve', 'curl error 28'])) { + return new \RuntimeException( + 'Could not connect to the S3 endpoint within 15 seconds. Please verify the endpoint, bucket, credentials, region, and network/firewall settings.', + previous: $exception, + ); + } + + return $exception; + } } diff --git a/app/Models/StandaloneClickhouse.php b/app/Models/StandaloneClickhouse.php index 784e2c9379..b104be642d 100644 --- a/app/Models/StandaloneClickhouse.php +++ b/app/Models/StandaloneClickhouse.php @@ -3,6 +3,7 @@ namespace App\Models; use App\Traits\ClearsGlobalSearchCache; +use App\Traits\HasDatabaseHealthCheck; use App\Traits\HasMetrics; use App\Traits\HasSafeStringAttribute; use Illuminate\Database\Eloquent\Casts\Attribute; @@ -11,7 +12,7 @@ use Illuminate\Database\Eloquent\SoftDeletes; class StandaloneClickhouse extends BaseModel { - use ClearsGlobalSearchCache, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes; + use ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes; protected $fillable = [ 'uuid', @@ -44,11 +45,21 @@ class StandaloneClickhouse extends BaseModel 'destination_type', 'destination_id', 'environment_id', + 'health_check_enabled', + 'health_check_interval', + 'health_check_timeout', + 'health_check_retries', + 'health_check_start_period', ]; protected $appends = ['internal_db_url', 'external_db_url', 'database_type', 'server_status']; protected $casts = [ + 'health_check_enabled' => 'boolean', + 'health_check_interval' => 'integer', + 'health_check_timeout' => 'integer', + 'health_check_retries' => 'integer', + 'health_check_start_period' => 'integer', 'clickhouse_admin_password' => 'encrypted', 'public_port_timeout' => 'integer', 'restart_count' => 'integer', @@ -111,6 +122,7 @@ class StandaloneClickhouse extends BaseModel public function isConfigurationChanged(bool $save = false) { $newConfigHash = $this->image.$this->ports_mappings; + $newConfigHash .= $this->healthCheckConfigurationHash(); $newConfigHash .= json_encode($this->environment_variables()->get('value')->sort()); $newConfigHash = md5($newConfigHash); $oldConfigHash = data_get($this, 'config_hash'); diff --git a/app/Models/StandaloneDragonfly.php b/app/Models/StandaloneDragonfly.php index e07053c034..2232ec7725 100644 --- a/app/Models/StandaloneDragonfly.php +++ b/app/Models/StandaloneDragonfly.php @@ -3,6 +3,7 @@ namespace App\Models; use App\Traits\ClearsGlobalSearchCache; +use App\Traits\HasDatabaseHealthCheck; use App\Traits\HasMetrics; use App\Traits\HasSafeStringAttribute; use Illuminate\Database\Eloquent\Casts\Attribute; @@ -11,7 +12,7 @@ use Illuminate\Database\Eloquent\SoftDeletes; class StandaloneDragonfly extends BaseModel { - use ClearsGlobalSearchCache, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes; + use ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes; protected $fillable = [ 'uuid', @@ -43,11 +44,21 @@ class StandaloneDragonfly extends BaseModel 'destination_type', 'destination_id', 'environment_id', + 'health_check_enabled', + 'health_check_interval', + 'health_check_timeout', + 'health_check_retries', + 'health_check_start_period', ]; protected $appends = ['internal_db_url', 'external_db_url', 'database_type', 'server_status']; protected $casts = [ + 'health_check_enabled' => 'boolean', + 'health_check_interval' => 'integer', + 'health_check_timeout' => 'integer', + 'health_check_retries' => 'integer', + 'health_check_start_period' => 'integer', 'dragonfly_password' => 'encrypted', 'public_port_timeout' => 'integer', 'restart_count' => 'integer', @@ -110,6 +121,7 @@ class StandaloneDragonfly extends BaseModel public function isConfigurationChanged(bool $save = false) { $newConfigHash = $this->image.$this->ports_mappings; + $newConfigHash .= $this->healthCheckConfigurationHash(); $newConfigHash .= json_encode($this->environment_variables()->get('value')->sort()); $newConfigHash = md5($newConfigHash); $oldConfigHash = data_get($this, 'config_hash'); diff --git a/app/Models/StandaloneKeydb.php b/app/Models/StandaloneKeydb.php index 979f45a3db..b9f9f765b4 100644 --- a/app/Models/StandaloneKeydb.php +++ b/app/Models/StandaloneKeydb.php @@ -3,6 +3,7 @@ namespace App\Models; use App\Traits\ClearsGlobalSearchCache; +use App\Traits\HasDatabaseHealthCheck; use App\Traits\HasMetrics; use App\Traits\HasSafeStringAttribute; use Illuminate\Database\Eloquent\Casts\Attribute; @@ -11,7 +12,7 @@ use Illuminate\Database\Eloquent\SoftDeletes; class StandaloneKeydb extends BaseModel { - use ClearsGlobalSearchCache, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes; + use ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes; protected $fillable = [ 'uuid', @@ -44,11 +45,21 @@ class StandaloneKeydb extends BaseModel 'destination_type', 'destination_id', 'environment_id', + 'health_check_enabled', + 'health_check_interval', + 'health_check_timeout', + 'health_check_retries', + 'health_check_start_period', ]; protected $appends = ['internal_db_url', 'external_db_url', 'server_status']; protected $casts = [ + 'health_check_enabled' => 'boolean', + 'health_check_interval' => 'integer', + 'health_check_timeout' => 'integer', + 'health_check_retries' => 'integer', + 'health_check_start_period' => 'integer', 'keydb_password' => 'encrypted', 'public_port_timeout' => 'integer', 'restart_count' => 'integer', @@ -111,6 +122,7 @@ class StandaloneKeydb extends BaseModel public function isConfigurationChanged(bool $save = false) { $newConfigHash = $this->image.$this->ports_mappings.$this->keydb_conf; + $newConfigHash .= $this->healthCheckConfigurationHash(); $newConfigHash .= json_encode($this->environment_variables()->get('value')->sort()); $newConfigHash = md5($newConfigHash); $oldConfigHash = data_get($this, 'config_hash'); diff --git a/app/Models/StandaloneMariadb.php b/app/Models/StandaloneMariadb.php index dba8a52f54..cd94b6c9bf 100644 --- a/app/Models/StandaloneMariadb.php +++ b/app/Models/StandaloneMariadb.php @@ -3,6 +3,7 @@ namespace App\Models; use App\Traits\ClearsGlobalSearchCache; +use App\Traits\HasDatabaseHealthCheck; use App\Traits\HasMetrics; use App\Traits\HasSafeStringAttribute; use Illuminate\Database\Eloquent\Casts\Attribute; @@ -12,7 +13,7 @@ use Illuminate\Database\Eloquent\SoftDeletes; class StandaloneMariadb extends BaseModel { - use ClearsGlobalSearchCache, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes; + use ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes; protected $fillable = [ 'uuid', @@ -47,11 +48,21 @@ class StandaloneMariadb extends BaseModel 'destination_type', 'destination_id', 'environment_id', + 'health_check_enabled', + 'health_check_interval', + 'health_check_timeout', + 'health_check_retries', + 'health_check_start_period', ]; protected $appends = ['internal_db_url', 'external_db_url', 'database_type', 'server_status']; protected $casts = [ + 'health_check_enabled' => 'boolean', + 'health_check_interval' => 'integer', + 'health_check_timeout' => 'integer', + 'health_check_retries' => 'integer', + 'health_check_start_period' => 'integer', 'mariadb_password' => 'encrypted', 'public_port_timeout' => 'integer', 'restart_count' => 'integer', @@ -114,6 +125,7 @@ class StandaloneMariadb extends BaseModel public function isConfigurationChanged(bool $save = false) { $newConfigHash = $this->image.$this->ports_mappings.$this->mariadb_conf; + $newConfigHash .= $this->healthCheckConfigurationHash(); $newConfigHash .= json_encode($this->environment_variables()->get('value')->sort()); $newConfigHash = md5($newConfigHash); $oldConfigHash = data_get($this, 'config_hash'); diff --git a/app/Models/StandaloneMongodb.php b/app/Models/StandaloneMongodb.php index e72f4f1c61..7d2ffbd74f 100644 --- a/app/Models/StandaloneMongodb.php +++ b/app/Models/StandaloneMongodb.php @@ -3,6 +3,7 @@ namespace App\Models; use App\Traits\ClearsGlobalSearchCache; +use App\Traits\HasDatabaseHealthCheck; use App\Traits\HasMetrics; use App\Traits\HasSafeStringAttribute; use Illuminate\Database\Eloquent\Casts\Attribute; @@ -11,7 +12,7 @@ use Illuminate\Database\Eloquent\SoftDeletes; class StandaloneMongodb extends BaseModel { - use ClearsGlobalSearchCache, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes; + use ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes; protected $fillable = [ 'uuid', @@ -47,11 +48,21 @@ class StandaloneMongodb extends BaseModel 'destination_type', 'destination_id', 'environment_id', + 'health_check_enabled', + 'health_check_interval', + 'health_check_timeout', + 'health_check_retries', + 'health_check_start_period', ]; protected $appends = ['internal_db_url', 'external_db_url', 'database_type', 'server_status']; protected $casts = [ + 'health_check_enabled' => 'boolean', + 'health_check_interval' => 'integer', + 'health_check_timeout' => 'integer', + 'health_check_retries' => 'integer', + 'health_check_start_period' => 'integer', 'public_port_timeout' => 'integer', 'restart_count' => 'integer', 'last_restart_at' => 'datetime', @@ -120,6 +131,7 @@ class StandaloneMongodb extends BaseModel public function isConfigurationChanged(bool $save = false) { $newConfigHash = $this->image.$this->ports_mappings.$this->mongo_conf; + $newConfigHash .= $this->healthCheckConfigurationHash(); $newConfigHash .= json_encode($this->environment_variables()->get('value')->sort()); $newConfigHash = md5($newConfigHash); $oldConfigHash = data_get($this, 'config_hash'); diff --git a/app/Models/StandaloneMysql.php b/app/Models/StandaloneMysql.php index 1c522d2005..f752312d34 100644 --- a/app/Models/StandaloneMysql.php +++ b/app/Models/StandaloneMysql.php @@ -3,6 +3,7 @@ namespace App\Models; use App\Traits\ClearsGlobalSearchCache; +use App\Traits\HasDatabaseHealthCheck; use App\Traits\HasMetrics; use App\Traits\HasSafeStringAttribute; use Illuminate\Database\Eloquent\Casts\Attribute; @@ -11,7 +12,7 @@ use Illuminate\Database\Eloquent\SoftDeletes; class StandaloneMysql extends BaseModel { - use ClearsGlobalSearchCache, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes; + use ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes; protected $fillable = [ 'uuid', @@ -48,11 +49,21 @@ class StandaloneMysql extends BaseModel 'destination_type', 'destination_id', 'environment_id', + 'health_check_enabled', + 'health_check_interval', + 'health_check_timeout', + 'health_check_retries', + 'health_check_start_period', ]; protected $appends = ['internal_db_url', 'external_db_url', 'database_type', 'server_status']; protected $casts = [ + 'health_check_enabled' => 'boolean', + 'health_check_interval' => 'integer', + 'health_check_timeout' => 'integer', + 'health_check_retries' => 'integer', + 'health_check_start_period' => 'integer', 'mysql_password' => 'encrypted', 'mysql_root_password' => 'encrypted', 'public_port_timeout' => 'integer', @@ -116,6 +127,7 @@ class StandaloneMysql extends BaseModel public function isConfigurationChanged(bool $save = false) { $newConfigHash = $this->image.$this->ports_mappings.$this->mysql_conf; + $newConfigHash .= $this->healthCheckConfigurationHash(); $newConfigHash .= json_encode($this->environment_variables()->get('value')->sort()); $newConfigHash = md5($newConfigHash); $oldConfigHash = data_get($this, 'config_hash'); diff --git a/app/Models/StandalonePostgresql.php b/app/Models/StandalonePostgresql.php index 57dfe59880..04d2291b39 100644 --- a/app/Models/StandalonePostgresql.php +++ b/app/Models/StandalonePostgresql.php @@ -3,6 +3,7 @@ namespace App\Models; use App\Traits\ClearsGlobalSearchCache; +use App\Traits\HasDatabaseHealthCheck; use App\Traits\HasMetrics; use App\Traits\HasSafeStringAttribute; use Illuminate\Database\Eloquent\Casts\Attribute; @@ -11,7 +12,7 @@ use Illuminate\Database\Eloquent\SoftDeletes; class StandalonePostgresql extends BaseModel { - use ClearsGlobalSearchCache, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes; + use ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes; protected $fillable = [ 'uuid', @@ -50,11 +51,21 @@ class StandalonePostgresql extends BaseModel 'destination_type', 'destination_id', 'environment_id', + 'health_check_enabled', + 'health_check_interval', + 'health_check_timeout', + 'health_check_retries', + 'health_check_start_period', ]; protected $appends = ['internal_db_url', 'external_db_url', 'database_type', 'server_status']; protected $casts = [ + 'health_check_enabled' => 'boolean', + 'health_check_interval' => 'integer', + 'health_check_timeout' => 'integer', + 'health_check_retries' => 'integer', + 'health_check_start_period' => 'integer', 'init_scripts' => 'array', 'postgres_password' => 'encrypted', 'public_port_timeout' => 'integer', @@ -158,6 +169,7 @@ class StandalonePostgresql extends BaseModel public function isConfigurationChanged(bool $save = false) { $newConfigHash = $this->image.$this->ports_mappings.$this->postgres_initdb_args.$this->postgres_host_auth_method; + $newConfigHash .= $this->healthCheckConfigurationHash(); $newConfigHash .= json_encode($this->environment_variables()->get('value')->sort()); $newConfigHash = md5($newConfigHash); $oldConfigHash = data_get($this, 'config_hash'); diff --git a/app/Models/StandaloneRedis.php b/app/Models/StandaloneRedis.php index ef42d7f180..efb0254fbc 100644 --- a/app/Models/StandaloneRedis.php +++ b/app/Models/StandaloneRedis.php @@ -3,6 +3,7 @@ namespace App\Models; use App\Traits\ClearsGlobalSearchCache; +use App\Traits\HasDatabaseHealthCheck; use App\Traits\HasMetrics; use App\Traits\HasSafeStringAttribute; use Illuminate\Database\Eloquent\Casts\Attribute; @@ -11,7 +12,7 @@ use Illuminate\Database\Eloquent\SoftDeletes; class StandaloneRedis extends BaseModel { - use ClearsGlobalSearchCache, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes; + use ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes; protected $fillable = [ 'uuid', @@ -43,11 +44,21 @@ class StandaloneRedis extends BaseModel 'destination_type', 'destination_id', 'environment_id', + 'health_check_enabled', + 'health_check_interval', + 'health_check_timeout', + 'health_check_retries', + 'health_check_start_period', ]; protected $appends = ['internal_db_url', 'external_db_url', 'database_type', 'server_status']; protected $casts = [ + 'health_check_enabled' => 'boolean', + 'health_check_interval' => 'integer', + 'health_check_timeout' => 'integer', + 'health_check_retries' => 'integer', + 'health_check_start_period' => 'integer', 'public_port_timeout' => 'integer', 'restart_count' => 'integer', 'last_restart_at' => 'datetime', @@ -115,6 +126,7 @@ class StandaloneRedis extends BaseModel public function isConfigurationChanged(bool $save = false) { $newConfigHash = $this->image.$this->ports_mappings.$this->redis_conf; + $newConfigHash .= $this->healthCheckConfigurationHash(); $newConfigHash .= json_encode($this->environment_variables()->get('value')->sort()); $newConfigHash = md5($newConfigHash); $oldConfigHash = data_get($this, 'config_hash'); diff --git a/app/Models/User.php b/app/Models/User.php index 237f3836f3..cefdf3d3e2 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -98,8 +98,24 @@ class User extends Authenticatable implements SendsEmail $team['id'] = 0; $team['name'] = 'Root Team'; } + $new_team = $user->id === 0 ? Team::find(0) : null; + + if ($new_team !== null) { + $new_team->forceFill($team); + $new_team->save(); + + if (! $user->teams()->whereKey($new_team->id)->exists()) { + $user->teams()->attach($new_team, ['role' => 'owner']); + } else { + $user->teams()->updateExistingPivot($new_team->id, ['role' => 'owner']); + } + + return; + } + $new_team = (new Team)->forceFill($team); $new_team->save(); + $user->teams()->attach($new_team, ['role' => 'owner']); }); diff --git a/app/Services/DeploymentConfiguration/ApplicationConfigurationSnapshot.php b/app/Services/DeploymentConfiguration/ApplicationConfigurationSnapshot.php index 676b22b6c0..8369f9a90d 100644 --- a/app/Services/DeploymentConfiguration/ApplicationConfigurationSnapshot.php +++ b/app/Services/DeploymentConfiguration/ApplicationConfigurationSnapshot.php @@ -306,7 +306,7 @@ class ApplicationConfigurationSnapshot private function displayValue(mixed $value): string { if ($value === null) { - return 'Not set'; + return '-'; } if (is_bool($value)) { @@ -323,7 +323,7 @@ class ApplicationConfigurationSnapshot private function summarizeText(?string $value): string { if (blank($value)) { - return 'Not set'; + return '-'; } $value = trim((string) $value); diff --git a/app/Services/DeploymentConfiguration/ConfigurationDiffer.php b/app/Services/DeploymentConfiguration/ConfigurationDiffer.php index 27e8d4c3f8..b101b9d5b7 100644 --- a/app/Services/DeploymentConfiguration/ConfigurationDiffer.php +++ b/app/Services/DeploymentConfiguration/ConfigurationDiffer.php @@ -37,8 +37,8 @@ class ConfigurationDiffer 'impact' => data_get($item, 'impact', 'redeploy'), 'sensitive' => $sensitive, 'display_summary' => $displaySummary, - 'old_display_value' => $sensitive ? ($previous === null ? 'Not set' : 'Set') : data_get($previous, 'display_value', 'Not set'), - 'new_display_value' => $sensitive ? ($current === null ? 'Removed' : 'Set') : data_get($current, 'display_value', 'Not set'), + 'old_display_value' => $sensitive ? ($previous === null ? '-' : '••••••••') : data_get($previous, 'display_value', '-'), + 'new_display_value' => $sensitive ? ($current === null ? '-' : '••••••••') : data_get($current, 'display_value', '-'), ]; } diff --git a/app/Traits/HasDatabaseHealthCheck.php b/app/Traits/HasDatabaseHealthCheck.php new file mode 100644 index 0000000000..62ca345ed8 --- /dev/null +++ b/app/Traits/HasDatabaseHealthCheck.php @@ -0,0 +1,45 @@ +health_check_enabled ?? true); + } + + /** + * Build the Docker Compose healthcheck block for the given probe command. + * + * @param array $test The Docker `test` array (e.g. ['CMD', 'pg_isready']). + * @return array + */ + public function healthCheckConfiguration(array $test): array + { + return [ + 'test' => $test, + 'interval' => ($this->health_check_interval ?? 15).'s', + 'timeout' => ($this->health_check_timeout ?? 5).'s', + 'retries' => $this->health_check_retries ?? 5, + 'start_period' => ($this->health_check_start_period ?? 5).'s', + ]; + } + + protected function healthCheckConfigurationHash(): string + { + return implode('|', [ + (int) ($this->health_check_enabled ?? true), + $this->health_check_interval ?? 15, + $this->health_check_timeout ?? 5, + $this->health_check_retries ?? 5, + $this->health_check_start_period ?? 5, + ]); + } +} diff --git a/app/Traits/HasDatabaseStatusInfo.php b/app/Traits/HasDatabaseStatusInfo.php new file mode 100644 index 0000000000..e46cccf0c0 --- /dev/null +++ b/app/Traits/HasDatabaseStatusInfo.php @@ -0,0 +1,172 @@ + 'refresh']; + + $user = Auth::user(); + if (! $user) { + return $listeners; + } + + $listeners["echo-private:user.{$user->id},DatabaseStatusChanged"] = 'refresh'; + + $team = $user->currentTeam(); + if ($team) { + $listeners["echo-private:team.{$team->id},ServiceChecked"] = 'refresh'; + } + + return $listeners; + } + + public function mount(): void + { + $this->refresh(); + } + + public function refresh(): void + { + $this->database->refresh(); + $this->dbUrl = $this->database->internal_db_url; + $this->dbUrlPublic = $this->database->external_db_url; + if ($this->supportsSsl()) { + $this->enableSsl = (bool) $this->database->enable_ssl; + $this->certificateValidUntil = $this->database->sslCertificates()->first()?->valid_until; + $this->afterRefresh(); + } + } + + /** + * Hook for subclasses with extra status-derived properties (e.g. sslMode). + */ + protected function afterRefresh(): void {} + + public function instantSaveSSL(): void + { + try { + $this->authorize('update', $this->database); + $this->database->enable_ssl = $this->enableSsl; + $this->applyExtraSslAttributes(); + $this->database->save(); + $this->dispatch('success', 'SSL configuration updated.'); + } catch (Exception $e) { + handleError($e, $this); + } + } + + /** + * Hook for subclasses with additional SSL columns to persist (e.g. ssl_mode). + */ + protected function applyExtraSslAttributes(): void {} + + public function regenerateSslCertificate(): void + { + try { + $this->authorize('update', $this->database); + + $existingCert = $this->database->sslCertificates()->first(); + + if (! $existingCert) { + $this->dispatch('error', 'No existing SSL certificate found for this database.'); + + return; + } + + $server = $this->database->destination->server; + $caCert = $server->sslCertificates()->where('is_ca_certificate', true)->first(); + + if (! $caCert) { + $server->generateCaCertificate(); + $caCert = $server->sslCertificates()->where('is_ca_certificate', true)->first(); + } + + if (! $caCert) { + $this->dispatch('error', 'No CA certificate found for this database. Please generate a CA certificate for this server in the server/advanced page.'); + + return; + } + + SslHelper::generateSslCertificate( + commonName: $existingCert->common_name, + subjectAlternativeNames: $existingCert->subject_alternative_names ?? [], + resourceType: $existingCert->resource_type, + resourceId: $existingCert->resource_id, + serverId: $existingCert->server_id, + caCert: $caCert->ssl_certificate, + caKey: $caCert->ssl_private_key, + configurationDir: $existingCert->configuration_dir, + mountPath: $existingCert->mount_path, + isPemKeyFileRequired: true, + ); + + $this->refresh(); + $this->dispatch('success', 'SSL certificates regenerated. Restart database to apply changes.'); + } catch (Exception $e) { + handleError($e, $this); + } + } + + public function render(): View + { + return view('livewire.project.database.status-info', [ + 'label' => $this->databaseLabel(), + 'supportsSsl' => $this->supportsSsl(), + 'sslModeOptions' => $this->sslModeOptions(), + 'sslModeHelper' => $this->sslModeHelper(), + 'showPublicUrlPlaceholder' => $this->showPublicUrlPlaceholder(), + 'isExited' => str($this->database->status)->contains('exited'), + ]); + } +} diff --git a/bootstrap/helpers/shared.php b/bootstrap/helpers/shared.php index ede21adfd9..f2f0e0a1cb 100644 --- a/bootstrap/helpers/shared.php +++ b/bootstrap/helpers/shared.php @@ -354,14 +354,30 @@ function showBoarding(): bool function refreshSession(?Team $team = null): void { if (! $team) { - if (Auth::user()->currentTeam()) { - $team = Team::find(Auth::user()->currentTeam()->id); - } else { - $team = User::find(Auth::id())->teams->first(); + $currentTeam = Auth::user()->currentTeam(); + if ($currentTeam) { + // currentTeam() can resolve a stale (just-deleted) team from the + // session/cache, so Team::find() may still return null here. + $team = Team::find($currentTeam->id); + } + if (! $team) { + // Fall back to any team the user still belongs to. + $team = User::query()->find(Auth::id())?->teams()->first(); } } + // Clear old cache key format for backwards compatibility Cache::forget('team:'.Auth::id()); + + if (! $team) { + // The user has no team left (e.g. just deleted their current team and + // belongs to no other): clear the stale session reference instead of + // dereferencing null. + session()->forget('currentTeam'); + + return; + } + // Use new cache key format that includes team ID Cache::forget('user:'.Auth::id().':team:'.$team->id); Cache::remember('user:'.Auth::id().':team:'.$team->id, 3600, function () use ($team) { diff --git a/composer.lock b/composer.lock index 24eb0bf734..7d958a9cc3 100644 --- a/composer.lock +++ b/composer.lock @@ -9667,16 +9667,16 @@ }, { "name": "symfony/polyfill-intl-idn", - "version": "v1.37.0", + "version": "v1.38.1", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-intl-idn.git", - "reference": "9614ac4d8061dc257ecc64cba1b140873dce8ad3" + "reference": "dc21118016c039a66235cf93d96b435ffb282412" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-intl-idn/zipball/9614ac4d8061dc257ecc64cba1b140873dce8ad3", - "reference": "9614ac4d8061dc257ecc64cba1b140873dce8ad3", + "url": "https://api.github.com/repos/symfony/polyfill-intl-idn/zipball/dc21118016c039a66235cf93d96b435ffb282412", + "reference": "dc21118016c039a66235cf93d96b435ffb282412", "shasum": "" }, "require": { @@ -9730,7 +9730,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-idn/tree/v1.37.0" + "source": "https://github.com/symfony/polyfill-intl-idn/tree/v1.38.1" }, "funding": [ { @@ -9750,20 +9750,20 @@ "type": "tidelift" } ], - "time": "2024-09-10T14:38:51+00:00" + "time": "2026-05-25T15:22:23+00:00" }, { "name": "symfony/polyfill-intl-normalizer", - "version": "v1.37.0", + "version": "v1.38.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-intl-normalizer.git", - "reference": "3833d7255cc303546435cb650316bff708a1c75c" + "reference": "2d446c214bdbe5b71bde5011b060a05fece3ae6b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/3833d7255cc303546435cb650316bff708a1c75c", - "reference": "3833d7255cc303546435cb650316bff708a1c75c", + "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/2d446c214bdbe5b71bde5011b060a05fece3ae6b", + "reference": "2d446c214bdbe5b71bde5011b060a05fece3ae6b", "shasum": "" }, "require": { @@ -9815,7 +9815,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.37.0" + "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.38.0" }, "funding": [ { @@ -9835,20 +9835,20 @@ "type": "tidelift" } ], - "time": "2024-09-09T11:45:10+00:00" + "time": "2026-05-25T13:48:31+00:00" }, { "name": "symfony/polyfill-mbstring", - "version": "v1.37.0", + "version": "v1.38.1", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-mbstring.git", - "reference": "6a21eb99c6973357967f6ce3708cd55a6bec6315" + "reference": "14c5439eec4ccff081ac14eca2dc57feb2a66d92" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/6a21eb99c6973357967f6ce3708cd55a6bec6315", - "reference": "6a21eb99c6973357967f6ce3708cd55a6bec6315", + "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/14c5439eec4ccff081ac14eca2dc57feb2a66d92", + "reference": "14c5439eec4ccff081ac14eca2dc57feb2a66d92", "shasum": "" }, "require": { @@ -9900,7 +9900,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.37.0" + "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.38.1" }, "funding": [ { @@ -9920,7 +9920,7 @@ "type": "tidelift" } ], - "time": "2026-04-10T17:25:58+00:00" + "time": "2026-05-26T12:51:13+00:00" }, { "name": "symfony/polyfill-php80", @@ -18072,5 +18072,5 @@ "php": "^8.4" }, "platform-dev": {}, - "plugin-api-version": "2.6.0" + "plugin-api-version": "2.9.0" } diff --git a/config/constants.php b/config/constants.php index 0a0227ea6c..89b633650d 100644 --- a/config/constants.php +++ b/config/constants.php @@ -2,9 +2,9 @@ return [ 'coolify' => [ - 'version' => '4.1.1', + 'version' => '4.1.2', 'helper_version' => '1.0.14', - 'realtime_version' => '1.0.15', + 'realtime_version' => '1.0.16', 'railpack_version' => '0.23.0', 'self_hosted' => env('SELF_HOSTED', true), 'autoupdate' => env('AUTOUPDATE'), @@ -67,13 +67,6 @@ return [ 'ssh' => [ 'mux_enabled' => env('MUX_ENABLED', env('SSH_MUX_ENABLED', true)), 'mux_persist_time' => env('SSH_MUX_PERSIST_TIME', 3600), - 'mux_health_check_enabled' => env('SSH_MUX_HEALTH_CHECK_ENABLED', true), - 'mux_health_check_timeout' => env('SSH_MUX_HEALTH_CHECK_TIMEOUT', 5), - 'mux_max_age' => env('SSH_MUX_MAX_AGE', 1800), // 30 minutes - 'mux_lock_ttl' => env('SSH_MUX_LOCK_TTL', 30), // lock auto-release, seconds - 'mux_lock_timeout' => env('SSH_MUX_LOCK_TIMEOUT', 10), // max wait for lock, seconds - 'mux_orphan_min_age' => env('SSH_MUX_ORPHAN_MIN_AGE', 600), // min process age before reaping orphans, seconds - 'mux_orphan_reap_enabled' => env('SSH_MUX_ORPHAN_REAP_ENABLED', false), // false = dry-run, only log orphans 'connection_timeout' => 10, 'server_interval' => 20, 'command_timeout' => 3600, @@ -100,9 +93,11 @@ return [ 'sentinel' => [ // How often (seconds) PushServerUpdateJob is force-dispatched even when - // the container state hash is unchanged. Keeps last_online_at, - // exited-detection and storage checks from going stale. + // the container state hash is unchanged. Keeps exited-detection and + // storage checks from going stale without writing every resource row on + // every push. 'push_force_interval_seconds' => env('SENTINEL_PUSH_FORCE_INTERVAL_SECONDS', 300), + ], 'proxy' => [ diff --git a/config/database.php b/config/database.php index 94c27f0386..9238a70551 100644 --- a/config/database.php +++ b/config/database.php @@ -3,6 +3,24 @@ use Illuminate\Support\Str; use Pdo\Pgsql; +$parseDatabaseHosts = function (mixed $hosts, mixed $fallback = 'coolify-db'): array { + $parsedHosts = array_values(array_filter( + array_map('trim', explode(',', (string) $hosts)), + 'strlen', + )); + + if ($parsedHosts !== []) { + return $parsedHosts; + } + + $fallbackHosts = array_values(array_filter( + array_map('trim', explode(',', (string) $fallback)), + 'strlen', + )); + + return $fallbackHosts === [] ? ['coolify-db'] : $fallbackHosts; +}; + $pgsql = [ 'driver' => 'pgsql', 'url' => env('DATABASE_URL'), @@ -28,13 +46,13 @@ $pgsql = [ */ if (env('DB_READ_HOST')) { $pgsql['read'] = [ - 'host' => array_map('trim', explode(',', (string) env('DB_READ_HOST'))), + 'host' => $parseDatabaseHosts(env('DB_READ_HOST'), env('DB_HOST', 'coolify-db')), 'port' => env('DB_READ_PORT', env('DB_PORT', '5432')), 'username' => env('DB_READ_USERNAME', env('DB_USERNAME', 'coolify')), 'password' => env('DB_READ_PASSWORD', env('DB_PASSWORD', '')), ]; $pgsql['write'] = [ - 'host' => array_map('trim', explode(',', (string) env('DB_WRITE_HOST', env('DB_HOST', 'coolify-db')))), + 'host' => $parseDatabaseHosts(env('DB_WRITE_HOST'), env('DB_HOST', 'coolify-db')), 'port' => env('DB_WRITE_PORT', env('DB_PORT', '5432')), 'username' => env('DB_WRITE_USERNAME', env('DB_USERNAME', 'coolify')), 'password' => env('DB_WRITE_PASSWORD', env('DB_PASSWORD', '')), diff --git a/database/migrations/2026_05_27_000000_add_push_server_update_job_indexes.php b/database/migrations/2026_05_27_000000_add_push_server_update_job_indexes.php new file mode 100644 index 0000000000..e74929147b --- /dev/null +++ b/database/migrations/2026_05_27_000000_add_push_server_update_job_indexes.php @@ -0,0 +1,27 @@ +tables as $table) { + Schema::table($table, function (Blueprint $table) { + $table->boolean('health_check_enabled')->default(true); + $table->integer('health_check_interval')->default(15); + $table->integer('health_check_timeout')->default(5); + $table->integer('health_check_retries')->default(5); + $table->integer('health_check_start_period')->default(5); + }); + } + } + + public function down(): void + { + foreach ($this->tables as $table) { + Schema::table($table, function (Blueprint $table) { + $table->dropColumn([ + 'health_check_enabled', + 'health_check_interval', + 'health_check_timeout', + 'health_check_retries', + 'health_check_start_period', + ]); + }); + } + } +}; diff --git a/database/seeders/ProductionSeeder.php b/database/seeders/ProductionSeeder.php index 511af1a9f2..4d492a2973 100644 --- a/database/seeders/ProductionSeeder.php +++ b/database/seeders/ProductionSeeder.php @@ -32,6 +32,16 @@ class ProductionSeeder extends Seeder echo " Running in self-hosted mode.\n"; } + if (Team::find(0) === null) { + (new Team)->forceFill([ + 'id' => 0, + 'name' => 'Root Team', + 'description' => 'The root team', + 'personal_team' => true, + 'show_boarding' => true, + ])->save(); + } + if (User::find(0) !== null && Team::find(0) !== null) { if (DB::table('team_user')->where('user_id', 0)->first() === null) { DB::table('team_user')->insert([ diff --git a/database/seeders/RootUserSeeder.php b/database/seeders/RootUserSeeder.php index c4e93af636..9bc93a9a9e 100644 --- a/database/seeders/RootUserSeeder.php +++ b/database/seeders/RootUserSeeder.php @@ -3,6 +3,7 @@ namespace Database\Seeders; use App\Models\InstanceSettings; +use App\Models\Team; use App\Models\User; use Illuminate\Database\Seeder; use Illuminate\Support\Facades\Hash; @@ -52,6 +53,12 @@ class RootUserSeeder extends Seeder 'password' => Hash::make(env('ROOT_USER_PASSWORD')), ]); $user->save(); + + $team = Team::find(0); + if ($team !== null && ! $user->teams()->where('team_id', 0)->exists()) { + $user->teams()->attach($team, ['role' => 'owner']); + } + echo "\n SUCCESS Root user created successfully.\n\n"; } catch (\Exception $e) { echo "\n ERROR Failed to create root user: {$e->getMessage()}\n\n"; diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 50edc140fe..9c93678afe 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -129,7 +129,7 @@ services: networks: - coolify minio: - image: ghcr.io/coollabsio/maxio:latest + image: coollabsio/maxio:latest pull_policy: always container_name: coolify-minio ports: diff --git a/docker/coolify-realtime/package-lock.json b/docker/coolify-realtime/package-lock.json index 5c6fa94aa0..cdb29bffa0 100644 --- a/docker/coolify-realtime/package-lock.json +++ b/docker/coolify-realtime/package-lock.json @@ -10,7 +10,7 @@ "cookie": "1.1.1", "dotenv": "17.3.1", "node-pty": "1.1.0", - "ws": "8.19.0" + "ws": "8.20.1" } }, "node_modules/@xterm/addon-fit": { @@ -70,9 +70,9 @@ } }, "node_modules/ws": { - "version": "8.19.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", - "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", + "version": "8.20.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.1.tgz", + "integrity": "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==", "license": "MIT", "engines": { "node": ">=10.0.0" diff --git a/docker/coolify-realtime/package.json b/docker/coolify-realtime/package.json index 25bf786a87..9128c0c3fe 100644 --- a/docker/coolify-realtime/package.json +++ b/docker/coolify-realtime/package.json @@ -7,6 +7,6 @@ "cookie": "1.1.1", "dotenv": "17.3.1", "node-pty": "1.1.0", - "ws": "8.19.0" + "ws": "8.20.1" } } diff --git a/docker/coolify-realtime/soketi-entrypoint.sh b/docker/coolify-realtime/soketi-entrypoint.sh index 3bb85bdeb9..7197e4a0c6 100644 --- a/docker/coolify-realtime/soketi-entrypoint.sh +++ b/docker/coolify-realtime/soketi-entrypoint.sh @@ -1,35 +1,91 @@ #!/bin/sh -# Function to timestamp logs -# Check if the first argument is 'watch' if [ "$1" = "watch" ]; then WATCH_MODE="--watch" else WATCH_MODE="" fi -timestamp() { - date "+%Y-%m-%d %H:%M:%S" +log() { + echo "$(date '+%Y-%m-%d %H:%M:%S') [ENTRYPOINT] $*" } -# Start the terminal server in the background with logging -node $WATCH_MODE /terminal/terminal-server.js > >(while read line; do echo "$(timestamp) [TERMINAL] $line"; done) 2>&1 & +start_logger() { + prefix="$1" + fifo_path="$2" + + while read -r line; do + echo "$(date '+%Y-%m-%d %H:%M:%S') [$prefix] $line" + done < "$fifo_path" & +} + +cleanup() { + rm -f "$TERMINAL_LOG_FIFO" "$SOKETI_LOG_FIFO" +} + +TERMINAL_LOG_FIFO="/tmp/coolify-terminal-log.$$" +SOKETI_LOG_FIFO="/tmp/coolify-soketi-log.$$" + +rm -f "$TERMINAL_LOG_FIFO" "$SOKETI_LOG_FIFO" +mkfifo "$TERMINAL_LOG_FIFO" "$SOKETI_LOG_FIFO" + +trap cleanup EXIT + +log "Starting realtime container" +log "WATCH_MODE=${WATCH_MODE:-off}" +log "SOKETI_DEBUG=${SOKETI_DEBUG:-unset}" +log "NODE_OPTIONS=${NODE_OPTIONS:-unset}" + +start_logger "TERMINAL" "$TERMINAL_LOG_FIFO" +TERMINAL_LOGGER_PID=$! + +start_logger "SOKETI" "$SOKETI_LOG_FIFO" +SOKETI_LOGGER_PID=$! + +node $WATCH_MODE /terminal/terminal-server.js > "$TERMINAL_LOG_FIFO" 2>&1 & TERMINAL_PID=$! -# Start the Soketi process in the background with logging -node /app/bin/server.js start > >(while read line; do echo "$(timestamp) [SOKETI] $line"; done) 2>&1 & +log "Terminal server started pid=$TERMINAL_PID logger_pid=$TERMINAL_LOGGER_PID" + +node /app/bin/server.js start > "$SOKETI_LOG_FIFO" 2>&1 & SOKETI_PID=$! -# Function to forward signals to child processes +log "Soketi started pid=$SOKETI_PID logger_pid=$SOKETI_LOGGER_PID" + forward_signal() { - kill -$1 $TERMINAL_PID $SOKETI_PID + log "Forwarding signal $1 to terminal=$TERMINAL_PID soketi=$SOKETI_PID" + + kill -"$1" "$TERMINAL_PID" 2>/dev/null || true + kill -"$1" "$SOKETI_PID" 2>/dev/null || true } -# Forward SIGTERM to child processes trap 'forward_signal TERM' TERM +trap 'forward_signal INT' INT -# Wait for any process to exit -wait -n +while true; do + if ! kill -0 "$TERMINAL_PID" 2>/dev/null; then + wait "$TERMINAL_PID" + EXIT_CODE=$? -# Exit with status of process that exited first -exit $? + log "Terminal server exited code=$EXIT_CODE; stopping soketi pid=$SOKETI_PID" + + kill "$SOKETI_PID" 2>/dev/null || true + wait "$SOKETI_PID" 2>/dev/null || true + + exit "$EXIT_CODE" + fi + + if ! kill -0 "$SOKETI_PID" 2>/dev/null; then + wait "$SOKETI_PID" + EXIT_CODE=$? + + log "Soketi exited code=$EXIT_CODE; stopping terminal pid=$TERMINAL_PID" + + kill "$TERMINAL_PID" 2>/dev/null || true + wait "$TERMINAL_PID" 2>/dev/null || true + + exit "$EXIT_CODE" + fi + + sleep 1 +done diff --git a/openapi.json b/openapi.json index 77f5f0d09f..69e13298a6 100644 --- a/openapi.json +++ b/openapi.json @@ -4620,6 +4620,35 @@ "mysql_conf": { "type": "string", "description": "MySQL conf" + }, + "health_check_enabled": { + "type": "boolean", + "description": "Enable the database healthcheck probe.", + "default": true + }, + "health_check_interval": { + "type": "integer", + "description": "Healthcheck interval in seconds.", + "minimum": 1, + "default": 15 + }, + "health_check_timeout": { + "type": "integer", + "description": "Healthcheck timeout in seconds.", + "minimum": 1, + "default": 5 + }, + "health_check_retries": { + "type": "integer", + "description": "Healthcheck retries count.", + "minimum": 1, + "default": 5 + }, + "health_check_start_period": { + "type": "integer", + "description": "Healthcheck start period in seconds.", + "minimum": 0, + "default": 5 } }, "type": "object" diff --git a/openapi.yaml b/openapi.yaml index 178e355682..2766c397eb 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -2962,6 +2962,30 @@ paths: mysql_conf: type: string description: 'MySQL conf' + health_check_enabled: + type: boolean + description: 'Enable the database healthcheck probe.' + default: true + health_check_interval: + type: integer + description: 'Healthcheck interval in seconds.' + minimum: 1 + default: 15 + health_check_timeout: + type: integer + description: 'Healthcheck timeout in seconds.' + minimum: 1 + default: 5 + health_check_retries: + type: integer + description: 'Healthcheck retries count.' + minimum: 1 + default: 5 + health_check_start_period: + type: integer + description: 'Healthcheck start period in seconds.' + minimum: 0 + default: 5 type: object responses: '200': diff --git a/other/nightly/versions.json b/other/nightly/versions.json index 78b027918e..9c9a405aaa 100644 --- a/other/nightly/versions.json +++ b/other/nightly/versions.json @@ -1,7 +1,7 @@ { "coolify": { "v4": { - "version": "4.1.1" + "version": "4.1.2" }, "nightly": { "version": "4.2.0" @@ -10,7 +10,7 @@ "version": "1.0.14" }, "realtime": { - "version": "1.0.15" + "version": "1.0.16" }, "sentinel": { "version": "0.0.21" diff --git a/package-lock.json b/package-lock.json index ae5b214e58..9d495c412d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,7 +15,7 @@ "devDependencies": { "@tailwindcss/postcss": "4.1.18", "laravel-vite-plugin": "2.0.1", - "postcss": "8.5.6", + "postcss": "8.5.15", "tailwind-scrollbar": "4.0.2", "tailwindcss": "4.1.18", "vite": "7.3.2" @@ -1720,9 +1720,9 @@ } }, "node_modules/nanoid": { - "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", "dev": true, "funding": [ { @@ -1803,9 +1803,9 @@ } }, "node_modules/postcss": { - "version": "8.5.6", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", - "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", "dev": true, "funding": [ { @@ -1823,7 +1823,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.11", + "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, diff --git a/package.json b/package.json index eb199e5ea1..c3fb1bc5f6 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,7 @@ "devDependencies": { "@tailwindcss/postcss": "4.1.18", "laravel-vite-plugin": "2.0.1", - "postcss": "8.5.6", + "postcss": "8.5.15", "tailwind-scrollbar": "4.0.2", "tailwindcss": "4.1.18", "vite": "7.3.2" diff --git a/public/svgs/cloudflare-ddns.svg b/public/svgs/cloudflare-ddns.svg new file mode 100644 index 0000000000..efe800bcc8 --- /dev/null +++ b/public/svgs/cloudflare-ddns.svg @@ -0,0 +1,8 @@ + + + + + + DDNS + + diff --git a/public/svgs/emqx-enterprise.svg b/public/svgs/emqx-enterprise.svg new file mode 100644 index 0000000000..e67e1bffe7 --- /dev/null +++ b/public/svgs/emqx-enterprise.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/public/svgs/hermes-agent.png b/public/svgs/hermes-agent.png new file mode 100644 index 0000000000..0d4a8e82ac Binary files /dev/null and b/public/svgs/hermes-agent.png differ diff --git a/public/svgs/openobserve.svg b/public/svgs/openobserve.svg new file mode 100644 index 0000000000..c687d948b4 --- /dev/null +++ b/public/svgs/openobserve.svg @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/resources/views/components/database-status-info.blade.php b/resources/views/components/database-status-info.blade.php new file mode 100644 index 0000000000..4a9de3ca57 --- /dev/null +++ b/resources/views/components/database-status-info.blade.php @@ -0,0 +1,94 @@ +@props([ + 'database', + 'label', + 'dbUrl' => null, + 'dbUrlPublic' => null, + 'supportsSsl' => true, + 'enableSsl' => false, + 'sslMode' => null, + 'sslModeOptions' => null, + 'sslModeHelper' => null, + 'certificateValidUntil' => null, + 'isExited' => false, + 'showPublicUrlPlaceholder' => false, +]) + +@php + $urlHelper = 'If you change the user/password/port, this could be different. This is with the default values.'; +@endphp + +
+ + @if ($dbUrlPublic) + + @elseif ($showPublicUrlPlaceholder) + + @endif + + @if ($supportsSsl) +
+
+
+

SSL Configuration

+ @if ($enableSsl && $certificateValidUntil) + + @endif +
+
+ @if ($enableSsl && $certificateValidUntil) + Valid until: + @if (now()->gt($certificateValidUntil)) + {{ $certificateValidUntil->format('d.m.Y H:i:s') }} - Expired + @elseif(now()->addDays(30)->gt($certificateValidUntil)) + {{ $certificateValidUntil->format('d.m.Y H:i:s') }} - Expiring + soon + @else + {{ $certificateValidUntil->format('d.m.Y H:i:s') }} + @endif + + @endif +
+
+ @if ($isExited) + + @else + + @endif +
+ @if ($sslModeOptions && $enableSsl) +
+ @if ($isExited) + + @foreach ($sslModeOptions as $value => $option) + + @endforeach + + @else + + @foreach ($sslModeOptions as $value => $option) + + @endforeach + + @endif +
+ @endif +
+
+ @endif +
diff --git a/resources/views/components/deployment/configuration-diff.blade.php b/resources/views/components/deployment/configuration-diff.blade.php index ffc0cd34a7..f01481057a 100644 --- a/resources/views/components/deployment/configuration-diff.blade.php +++ b/resources/views/components/deployment/configuration-diff.blade.php @@ -4,9 +4,9 @@ ]) @php - $changes = data_get($diff, 'changes', []); - $count = data_get($diff, 'count', count($changes)); - $requiresBuild = data_get($diff, 'requires_build', false); + $changes = collect(data_get($diff, 'changes', []))->filter(fn ($change) => data_get($change, 'key') !== 'domains.custom_labels')->values()->all(); + $count = count($changes); + $requiresBuild = collect($changes)->contains(fn ($change) => data_get($change, 'impact') === 'build'); @endphp @if ($count > 0) @@ -21,45 +21,39 @@ 'bg-red-100 text-red-700 dark:bg-red-500/20 dark:text-red-300' => $requiresBuild, 'bg-blue-100 text-blue-700 dark:bg-blue-500/20 dark:text-blue-300' => ! $requiresBuild, ])> - {{ $requiresBuild ? 'Rebuild' : 'Redeploy' }} + {{ $requiresBuild ? 'Rebuild required' : 'Redeploy required' }} @unless ($compact) -
+
@foreach (collect($changes)->groupBy('section_label') as $sectionLabel => $sectionChanges)
{{ $sectionLabel }}
-
-
-
-
Field
-
Type
-
From
-
-
To
-
-
- @foreach ($sectionChanges as $change) -
-
- {{ data_get($change, 'label') }} -
-
- {{ data_get($change, 'type') }} -
-
- {{ data_get($change, 'old_display_value') }} -
-
→
-
- {{ data_get($change, 'new_display_value') }} -
+
+
+
Field
+
From
+
+
To
+
+
+ @foreach ($sectionChanges as $change) +
+
+ {{ data_get($change, 'label') }}
- @endforeach -
+
+ {{ data_get($change, 'old_display_value') }} +
+
→
+
+ {{ data_get($change, 'new_display_value') }} +
+
+ @endforeach
diff --git a/resources/views/components/navbar.blade.php b/resources/views/components/navbar.blade.php index 3b21a81d53..a8a9aaf768 100644 --- a/resources/views/components/navbar.blade.php +++ b/resources/views/components/navbar.blade.php @@ -49,23 +49,32 @@ localStorage.setItem('theme', type); this.queryTheme(); }, + cycleTheme() { + const themes = ['light', 'system', 'dark']; + const currentIndex = themes.indexOf(this.theme || localStorage.getItem('theme') || 'dark'); + this.setTheme(themes[(currentIndex + 1) % themes.length]); + }, queryTheme() { const darkModePreference = window.matchMedia('(prefers-color-scheme: dark)').matches; const userSettings = localStorage.getItem('theme') || 'dark'; localStorage.setItem('theme', userSettings); + let isDark = false; if (userSettings === 'dark') { document.documentElement.classList.add('dark'); this.theme = 'dark'; + isDark = true; } else if (userSettings === 'light') { document.documentElement.classList.remove('dark'); this.theme = 'light'; } else if (darkModePreference) { this.theme = 'system'; document.documentElement.classList.add('dark'); + isDark = true; } else if (!darkModePreference) { this.theme = 'system'; document.documentElement.classList.remove('dark'); } + document.querySelector('meta[name=theme-color]')?.setAttribute('content', isDark ? '#101010' : '#ffffff'); }, checkZoom() { if (this.zoom === null) { @@ -92,9 +101,9 @@ } } }"> -
-
+
+ @@ -107,10 +116,10 @@
-
+
-
- -
@@ -366,9 +372,73 @@ @endif @endif
+
  • + +
  • +
  • + + +
  • @if (isInstanceAdmin() && !isCloud()) @persist('upgrade') -
  • +
  • @endpersist @@ -420,7 +490,7 @@
  • @csrf - diff --git a/resources/views/components/profile/navbar.blade.php b/resources/views/components/profile/navbar.blade.php new file mode 100644 index 0000000000..4c1554ee31 --- /dev/null +++ b/resources/views/components/profile/navbar.blade.php @@ -0,0 +1,17 @@ + diff --git a/resources/views/livewire/destination/index.blade.php b/resources/views/livewire/destination/index.blade.php index d5026a651d..dcf317c2b3 100644 --- a/resources/views/livewire/destination/index.blade.php +++ b/resources/views/livewire/destination/index.blade.php @@ -32,7 +32,7 @@ {{ $destination->name }}
  • -
    server: {{ $destination->server->name }}
    +
    Server: {{ $destination->server->name }}
    @endif diff --git a/resources/views/livewire/profile/appearance.blade.php b/resources/views/livewire/profile/appearance.blade.php new file mode 100644 index 0000000000..45c2ac96cc --- /dev/null +++ b/resources/views/livewire/profile/appearance.blade.php @@ -0,0 +1,119 @@ +
    + + Appearance | Coolify + + + +
    +
    +

    Appearance

    +
    Choose how Coolify looks in this browser.
    +
    + + + +
    +
    + +
    +

    Width

    +
    Choose the maximum page width for this browser.
    +
    + + +
    +
    + +
    +

    Zoom

    +
    Choose interface density for this browser.
    +
    + + +
    +
    +
    +
    diff --git a/resources/views/livewire/profile/index.blade.php b/resources/views/livewire/profile/index.blade.php index 11031b7f2b..d5664fd689 100644 --- a/resources/views/livewire/profile/index.blade.php +++ b/resources/views/livewire/profile/index.blade.php @@ -2,8 +2,7 @@ Profile | Coolify -

    Profile

    -
    Your user profile settings.
    +

    General

    diff --git a/resources/views/livewire/project/application/advanced.blade.php b/resources/views/livewire/project/application/advanced.blade.php index 82ee319333..d6fcc2e4af 100644 --- a/resources/views/livewire/project/application/advanced.blade.php +++ b/resources/views/livewire/project/application/advanced.blade.php @@ -41,7 +41,7 @@ instantSave id="isPreviewDeploymentsEnabled" label="Preview Deployments" canGate="update" :canResource="$application" /> diff --git a/resources/views/livewire/project/application/configuration.blade.php b/resources/views/livewire/project/application/configuration.blade.php index 848c46ff77..6986cef051 100644 --- a/resources/views/livewire/project/application/configuration.blade.php +++ b/resources/views/livewire/project/application/configuration.blade.php @@ -27,21 +27,7 @@ @endif Servers - @if ($application->server_status == false) - - - - - - @elseif ($application->additional_servers()->exists() && str($application->status)->contains('degraded')) - - - - - - @endif + str($currentRoute)->startsWith('project.application.scheduled-tasks')]) {{ wireNavigate() }} href="{{ route('project.application.scheduled-tasks.show', ['project_uuid' => $project->uuid, 'environment_uuid' => $environment->uuid, 'application_uuid' => $application->uuid]) }}">Scheduled Tasks diff --git a/resources/views/livewire/project/application/deployment/show.blade.php b/resources/views/livewire/project/application/deployment/show.blade.php index 1eed3d486b..4c1a2f08a8 100644 --- a/resources/views/livewire/project/application/deployment/show.blade.php +++ b/resources/views/livewire/project/application/deployment/show.blade.php @@ -13,6 +13,7 @@ scrollDebounce: null, isScrolling: false, destroyed: false, + morphUpdatedCleanup: null, deploymentFinishedCleanup: null, lastTouchY: 0, showTimestamps: true, @@ -40,6 +41,10 @@ clearTimeout(this.scrollTimeout); this.scrollTimeout = null; } + if (this.scrollDebounce) { + clearTimeout(this.scrollDebounce); + this.scrollDebounce = null; + } }, disableFollow() { if (!this.alwaysScroll) return; @@ -208,10 +213,8 @@ }); // Apply search after Livewire updates. - // Livewire.hook() has no deregister API, so this callback survives - // wire:navigate. It is made harmless after teardown by the - // `destroyed` guard and by only reacting to DOM inside this root. - Livewire.hook('morph.updated', ({ el }) => { + // Livewire.hook() returns an unregister fn; keep it for destroy(). + this.morphUpdatedCleanup = Livewire.hook('morph.updated', ({ el }) => { if (this.destroyed) return; if (el.id !== 'logs' || !this.$root.contains(el)) return; this.$nextTick(() => { @@ -247,6 +250,10 @@ clearTimeout(this.scrollDebounce); this.scrollDebounce = null; } + if (typeof this.morphUpdatedCleanup === 'function') { + this.morphUpdatedCleanup(); + this.morphUpdatedCleanup = null; + } if (typeof this.deploymentFinishedCleanup === 'function') { this.deploymentFinishedCleanup(); this.deploymentFinishedCleanup = null; diff --git a/resources/views/livewire/project/application/server-status-badge.blade.php b/resources/views/livewire/project/application/server-status-badge.blade.php new file mode 100644 index 0000000000..80c786a3eb --- /dev/null +++ b/resources/views/livewire/project/application/server-status-badge.blade.php @@ -0,0 +1,17 @@ + + @if ($application->server_status === false) + + + + + + @elseif ($application->additional_servers()->exists() && str($application->status)->contains('degraded')) + + + + + + @endif + diff --git a/resources/views/livewire/project/database/clickhouse/general.blade.php b/resources/views/livewire/project/database/clickhouse/general.blade.php index 9283172ad0..acba65442f 100644 --- a/resources/views/livewire/project/database/clickhouse/general.blade.php +++ b/resources/views/livewire/project/database/clickhouse/general.blade.php @@ -41,19 +41,8 @@ helper="A comma separated list of ports you would like to map to the host system.
    Example3000:5432,3002:5433" canGate="update" :canResource="$database" />
    - - @if ($dbUrlPublic) - - @else - - @endif
    +
    -
    -
    -
    -

    SSL Configuration

    - @if ($database->enable_ssl && $certificateValidUntil) - - @endif -
    -
    - @if ($database->enable_ssl && $certificateValidUntil) - Valid until: - @if (now()->gt($certificateValidUntil)) - {{ $certificateValidUntil->format('d.m.Y H:i:s') }} - Expired - @elseif(now()->addDays(30)->gt($certificateValidUntil)) - {{ $certificateValidUntil->format('d.m.Y H:i:s') }} - Expiring - soon - @else - {{ $certificateValidUntil->format('d.m.Y H:i:s') }} - @endif - - @endif -
    -
    - @if (str($database->status)->contains('exited')) - - @else - - @endif -
    -
    +
    diff --git a/resources/views/livewire/project/database/health.blade.php b/resources/views/livewire/project/database/health.blade.php new file mode 100644 index 0000000000..7255002095 --- /dev/null +++ b/resources/views/livewire/project/database/health.blade.php @@ -0,0 +1,35 @@ + +
    +

    Healthcheck

    + Save + @if (!$healthCheckEnabled) + + + @else + Disable Healthcheck + @endif +
    +
    Define how your resource's health should be checked.
    +
    + @if (!$healthCheckEnabled) + +

    Docker runs no healthcheck probe for this database and Coolify can no longer report a healthy/unhealthy state.

    +
    + @endif + +
    + + + + +
    +
    + diff --git a/resources/views/livewire/project/database/import-form.blade.php b/resources/views/livewire/project/database/import-form.blade.php new file mode 100644 index 0000000000..1e384ac8d1 --- /dev/null +++ b/resources/views/livewire/project/database/import-form.blade.php @@ -0,0 +1,228 @@ +
    + + @script + + @endscript +
    + + + + This is a destructive action, existing data will be replaced! +
    + {{-- Restore Command Configuration --}} + @if ($resourceDbType === 'standalone-postgresql') + @if ($dumpAll) + + @else + +
    + You can add "--clean" to drop objects before creating them, avoiding + conflicts. + You can add "--verbose" to log more things. +
    + @endif +
    + +
    + @elseif ($resourceDbType === 'standalone-mysql') + @if ($dumpAll) + + @else + + @endif +
    + +
    + @elseif ($resourceDbType === 'standalone-mariadb') + @if ($dumpAll) + + @else + + @endif +
    + +
    + @endif + + {{-- Restore Type Selection Boxes --}} +

    Choose Restore Method

    +
    +
    +
    + + + +

    Restore from File

    +

    Upload a backup file or specify a file path on the server

    +
    +
    + + @if (count($availableS3Storages) > 0) +
    +
    + + + +

    Restore from S3

    +

    Download and restore a backup from S3 storage

    +
    +
    + @endif +
    + + {{-- File Restore Section --}} + @can('update', $this->resource) +
    +

    Backup File

    +
    + + Check File +
    +
    + Or +
    +
    + @csrf +
    +
    + +
    + +
    +

    File Information

    +
    Location:
    +
    + + + Restore Database from File + + This will perform the following actions: +
      +
    • Copy backup file to database container
    • +
    • Execute restore command
    • +
    +
    WARNING: This will REPLACE all existing data!
    +
    +
    +
    +
    + @endcan + + {{-- S3 Restore Section --}} + @if (count($availableS3Storages) > 0) + @can('update', $this->resource) +
    +

    Restore from S3

    +
    + + + @foreach ($availableS3Storages as $storage) + + @endforeach + + + + +
    + + Check File + +
    + + @if ($s3FileSize) +
    +

    File Information

    +
    Location: {{ $s3Path }} {{ formatBytes($s3FileSize ?? 0) }}
    +
    + + + Restore Database from S3 + + This will perform the following actions: +
      +
    • Download backup from S3 storage
    • +
    • Copy file into database container
    • +
    • Execute restore command
    • +
    +
    WARNING: This will REPLACE all existing data!
    +
    +
    +
    + @endif +
    +
    + @endcan + @endif + + {{-- Slide-over for activity monitor (all restore operations) --}} + + Database Restore Output + +
    + +
    +
    +
    +
    \ No newline at end of file diff --git a/resources/views/livewire/project/database/import.blade.php b/resources/views/livewire/project/database/import.blade.php index 666abb3b37..75de25f717 100644 --- a/resources/views/livewire/project/database/import.blade.php +++ b/resources/views/livewire/project/database/import.blade.php @@ -1,237 +1,10 @@ -
    - - @script - - @endscript +

    Import Backup

    @if ($unsupported)
    Database restore is not supported.
    + @elseif (str($resourceStatus)->startsWith('running')) + @else -
    - - - - This is a destructive action, existing data will be replaced! -
    - @if (str($resourceStatus)->startsWith('running')) - {{-- Restore Command Configuration --}} - @if ($resourceDbType === 'standalone-postgresql') - @if ($dumpAll) - - @else - -
    - You can add "--clean" to drop objects before creating them, avoiding - conflicts. - You can add "--verbose" to log more things. -
    - @endif -
    - -
    - @elseif ($resourceDbType === 'standalone-mysql') - @if ($dumpAll) - - @else - - @endif -
    - -
    - @elseif ($resourceDbType === 'standalone-mariadb') - @if ($dumpAll) - - @else - - @endif -
    - -
    - @endif - - {{-- Restore Type Selection Boxes --}} -

    Choose Restore Method

    -
    -
    -
    - - - -

    Restore from File

    -

    Upload a backup file or specify a file path on the server

    -
    -
    - - @if (count($availableS3Storages) > 0) -
    -
    - - - -

    Restore from S3

    -

    Download and restore a backup from S3 storage

    -
    -
    - @endif -
    - - {{-- File Restore Section --}} - @can('update', $this->resource) -
    -

    Backup File

    -
    - - Check File -
    -
    - Or -
    -
    - @csrf -
    -
    - -
    - -
    -

    File Information

    -
    Location:
    -
    - - - Restore Database from File - - This will perform the following actions: -
      -
    • Copy backup file to database container
    • -
    • Execute restore command
    • -
    -
    WARNING: This will REPLACE all existing data!
    -
    -
    -
    -
    - @endcan - - {{-- S3 Restore Section --}} - @if (count($availableS3Storages) > 0) - @can('update', $this->resource) -
    -

    Restore from S3

    -
    - - - @foreach ($availableS3Storages as $storage) - - @endforeach - - - - -
    - - Check File - -
    - - @if ($s3FileSize) -
    -

    File Information

    -
    Location: {{ $s3Path }} {{ formatBytes($s3FileSize ?? 0) }}
    -
    - - - Restore Database from S3 - - This will perform the following actions: -
      -
    • Download backup from S3 storage
    • -
    • Copy file into database container
    • -
    • Execute restore command
    • -
    -
    WARNING: This will REPLACE all existing data!
    -
    -
    -
    - @endif -
    -
    - @endcan - @endif - - {{-- Slide-over for activity monitor (all restore operations) --}} - - Database Restore Output - -
    - -
    -
    -
    - @else -
    Database must be running to restore a backup.
    - @endif +
    Database must be running to restore a backup.
    @endif -
    \ No newline at end of file +
    diff --git a/resources/views/livewire/project/database/keydb/general.blade.php b/resources/views/livewire/project/database/keydb/general.blade.php index ee3f8fd0c4..fa241dec26 100644 --- a/resources/views/livewire/project/database/keydb/general.blade.php +++ b/resources/views/livewire/project/database/keydb/general.blade.php @@ -38,59 +38,8 @@ helper="A comma separated list of ports you would like to map to the host system.
    Example3000:5432,3002:5433" canGate="update" :canResource="$database" />
    - - @if ($dbUrlPublic) - - @else - - @endif -
    -
    -
    -
    -

    SSL Configuration

    - @if ($database->enable_ssl && $certificateValidUntil) - - @endif -
    -
    - @if ($database->enable_ssl && $certificateValidUntil) - Valid until: - @if (now()->gt($certificateValidUntil)) - {{ $certificateValidUntil->format('d.m.Y H:i:s') }} - Expired - @elseif(now()->addDays(30)->gt($certificateValidUntil)) - {{ $certificateValidUntil->format('d.m.Y H:i:s') }} - Expiring - soon - @else - {{ $certificateValidUntil->format('d.m.Y H:i:s') }} - @endif - - @endif -
    -
    - @if (str($database->status)->contains('exited')) - - @else - - @endif -
    -
    +
    diff --git a/resources/views/livewire/project/database/mariadb/general.blade.php b/resources/views/livewire/project/database/mariadb/general.blade.php index 1154124d10..b29b3e81e9 100644 --- a/resources/views/livewire/project/database/mariadb/general.blade.php +++ b/resources/views/livewire/project/database/mariadb/general.blade.php @@ -61,59 +61,9 @@ helper="A comma separated list of ports you would like to map to the host system.
    Example3000:5432,3002:5433" canGate="update" :canResource="$database" />
    - - @if ($db_url_public) - - @endif
    -
    -
    -
    -

    SSL Configuration

    - @if ($enableSsl && $certificateValidUntil) - - @endif -
    -
    - @if ($enableSsl && $certificateValidUntil) - Valid until: - @if (now()->gt($certificateValidUntil)) - {{ $certificateValidUntil->format('d.m.Y H:i:s') }} - Expired - @elseif(now()->addDays(30)->gt($certificateValidUntil)) - {{ $certificateValidUntil->format('d.m.Y H:i:s') }} - Expiring - soon - @else - {{ $certificateValidUntil->format('d.m.Y H:i:s') }} - @endif - - @endif -
    -
    -
    -
    - @if (str($database->status)->contains('exited')) - - @else - - @endif -
    -
    -
    +
    diff --git a/resources/views/livewire/project/database/mongodb/general.blade.php b/resources/views/livewire/project/database/mongodb/general.blade.php index e9e5d621df..c1ec60219d 100644 --- a/resources/views/livewire/project/database/mongodb/general.blade.php +++ b/resources/views/livewire/project/database/mongodb/general.blade.php @@ -50,85 +50,10 @@ helper="A comma separated list of ports you would like to map to the host system.
    Example3000:5432,3002:5433" canGate="update" :canResource="$database" />
    - - @if ($db_url_public) - - @endif
    +
    -
    -
    -

    SSL Configuration

    - @if ($enableSsl) - - @endif -
    -
    - @if ($enableSsl && $certificateValidUntil) - Valid until: - @if (now()->gt($certificateValidUntil)) - {{ $certificateValidUntil->format('d.m.Y H:i:s') }} - Expired - @elseif(now()->addDays(30)->gt($certificateValidUntil)) - {{ $certificateValidUntil->format('d.m.Y H:i:s') }} - Expiring - soon - @else - {{ $certificateValidUntil->format('d.m.Y H:i:s') }} - @endif - - @endif -
    -
    -
    -
    - @if (str($database->status)->contains('exited')) - - @else - - @endif -
    - @if ($enableSsl) -
    - @if (str($database->status)->contains('exited')) - - - - - - - @else - - - - - - - @endif -
    - @endif -
    -
    diff --git a/resources/views/livewire/project/database/mysql/general.blade.php b/resources/views/livewire/project/database/mysql/general.blade.php index bb3916ec89..e90885e7c3 100644 --- a/resources/views/livewire/project/database/mysql/general.blade.php +++ b/resources/views/livewire/project/database/mysql/general.blade.php @@ -56,81 +56,9 @@
    - - @if ($db_url_public) - - @endif
    -
    -
    -
    -

    SSL Configuration

    - @if ($enableSsl && $certificateValidUntil) - - @endif -
    -
    - @if ($enableSsl && $certificateValidUntil) - Valid until: - @if (now()->gt($certificateValidUntil)) - {{ $certificateValidUntil->format('d.m.Y H:i:s') }} - Expired - @elseif(now()->addDays(30)->gt($certificateValidUntil)) - {{ $certificateValidUntil->format('d.m.Y H:i:s') }} - Expiring - soon - @else - {{ $certificateValidUntil->format('d.m.Y H:i:s') }} - @endif - - @endif -
    -
    -
    -
    - @if (str($database->status)->contains('exited')) - - @else - - @endif -
    - @if ($enableSsl) -
    - @if (str($database->status)->contains('exited')) - - - - - - - @else - - - - - - - @endif -
    - @endif -
    -
    +
    diff --git a/resources/views/livewire/project/database/postgresql/general.blade.php b/resources/views/livewire/project/database/postgresql/general.blade.php index 9c956f5b32..ab6f6ed888 100644 --- a/resources/views/livewire/project/database/postgresql/general.blade.php +++ b/resources/views/livewire/project/database/postgresql/general.blade.php @@ -68,114 +68,38 @@ helper="A comma separated list of ports you would like to map to the host system.
    Example3000:5432,3002:5433" canGate="update" :canResource="$database" />
    - - - @if ($db_url_public) - - @endif
    +
    -

    SSL Configuration

    - @if ($enableSsl && $certificateValidUntil) - +

    Proxy

    + + @if (data_get($database, 'is_public')) + + Proxy Logs + + + + Logs + @endif
    - @if ($enableSsl && $certificateValidUntil) - Valid until: - @if (now()->gt($certificateValidUntil)) - {{ $certificateValidUntil->format('d.m.Y H:i:s') }} - Expired - @elseif(now()->addDays(30)->gt($certificateValidUntil)) - {{ $certificateValidUntil->format('d.m.Y H:i:s') }} - Expiring - soon - @else - {{ $certificateValidUntil->format('d.m.Y H:i:s') }} - @endif - - @endif +
    + +
    +
    + + +
    -
    -
    - @if ($database->isExited()) - - @else - - @endif -
    - @if ($enableSsl) -
    - @if ($database->isExited()) - - - - - - - - @else - - - - - - - - @endif -
    - @endif - -
    -
    -

    Proxy

    - - @if (data_get($database, 'is_public')) - - Proxy Logs - - - - Logs - - @endif -
    -
    - -
    -
    - - -
    -
    - -
    - -
    -
    +
    diff --git a/resources/views/livewire/project/database/redis/general.blade.php b/resources/views/livewire/project/database/redis/general.blade.php index 73ee5f0e52..b72b05ff6b 100644 --- a/resources/views/livewire/project/database/redis/general.blade.php +++ b/resources/views/livewire/project/database/redis/general.blade.php @@ -60,56 +60,8 @@ helper="A comma separated list of ports you would like to map to the host system.
    Example3000:5432,3002:5433" canGate="update" :canResource="$database" />
    - - @if ($dbUrlPublic) - - @endif -
    -
    -
    -
    -

    SSL Configuration

    - @if ($enableSsl && $certificateValidUntil) - - @endif -
    -
    - @if ($enableSsl && $certificateValidUntil) - Valid until: - @if (now()->gt($certificateValidUntil)) - {{ $certificateValidUntil->format('d.m.Y H:i:s') }} - Expired - @elseif(now()->addDays(30)->gt($certificateValidUntil)) - {{ $certificateValidUntil->format('d.m.Y H:i:s') }} - Expiring - soon - @else - {{ $certificateValidUntil->format('d.m.Y H:i:s') }} - @endif - - @endif -
    -
    - @if (str($database->status)->contains('exited')) - - @else - - @endif -
    -
    +
    diff --git a/resources/views/livewire/project/database/status-info.blade.php b/resources/views/livewire/project/database/status-info.blade.php new file mode 100644 index 0000000000..7107b3daf8 --- /dev/null +++ b/resources/views/livewire/project/database/status-info.blade.php @@ -0,0 +1,6 @@ +
    + +
    diff --git a/resources/views/livewire/project/service/configuration.blade.php b/resources/views/livewire/project/service/configuration.blade.php index ffe80b5956..35b2ffd20f 100644 --- a/resources/views/livewire/project/service/configuration.blade.php +++ b/resources/views/livewire/project/service/configuration.blade.php @@ -43,134 +43,12 @@ @endif @foreach ($applications as $application) -
    str( - $application->status)->contains(['exited']), - 'border-l border-dashed border-success' => str( - $application->status)->contains(['running']), - 'border-l border-dashed border-warning' => str( - $application->status)->contains(['starting']), - 'flex gap-2 box-without-bg-without-border dark:bg-coolgray-100 bg-white dark:hover:text-neutral-300 group', - ])> - -
    + @endforeach @foreach ($databases as $database) -
    str($database->status)->contains( - ['exited']), - 'border-l border-dashed border-success' => str($database->status)->contains( - ['running']), - 'border-l border-dashed border-warning' => str($database->status)->contains( - ['restarting']), - 'flex gap-2 box-without-bg-without-border dark:bg-coolgray-100 bg-white dark:hover:text-neutral-300 group', - ])> - -
    + @endforeach
    @elseif ($currentRoute === 'project.service.environment-variables') diff --git a/resources/views/livewire/project/service/resource-card.blade.php b/resources/views/livewire/project/service/resource-card.blade.php new file mode 100644 index 0000000000..47fb009148 --- /dev/null +++ b/resources/views/livewire/project/service/resource-card.blade.php @@ -0,0 +1,77 @@ +
    str($resource->status)->contains(['exited']), + 'border-l border-dashed border-success' => str($resource->status)->contains(['running']), + 'border-l border-dashed border-warning' => str($resource->status)->contains(['starting', 'restarting']), + 'flex gap-2 box-without-bg-without-border dark:bg-coolgray-100 bg-white dark:hover:text-neutral-300 group', +])> +
    +
    +
    + @if ($resource->human_name) + {{ Str::headline($resource->human_name) }} + @else + {{ Str::headline($resource->name) }} + @endif + ({{ $resource->image }}) +
    + @if ($resource->configuration_required) + (configuration required) + @endif + @if ($resource->description) + {{ Str::limit($resource->description, 60) }} + @endif + @if ($isApplication && $resource->fqdn) + {{ Str::limit($resource->fqdn, 60) }} + @can('update', $service) + + + + + + + + + + + + + + @endcan + + @endif +
    $isApplication, 'text-xs'])>{{ formatContainerStatus($resource->status) }}
    +
    +
    + @if ($isDatabase && ($resource->isBackupSolutionAvailable() || $resource->is_migrated)) + + Backups + + @endif + + Settings + + @if (str($resource->status)->contains('running')) + @can('update', $service) + + @endcan + @endif +
    +
    +
    diff --git a/resources/views/livewire/project/shared/health-checks.blade.php b/resources/views/livewire/project/shared/health-checks.blade.php index 8662b0b50b..ac2063c2ee 100644 --- a/resources/views/livewire/project/shared/health-checks.blade.php +++ b/resources/views/livewire/project/shared/health-checks.blade.php @@ -1,6 +1,6 @@
    -

    Healthchecks

    +

    Healthcheck

    Save @if (!$healthCheckEnabled)

    Webhooks

    + helper="For more details goto our docs." />
    @if ($resource->type() === 'application') diff --git a/resources/views/livewire/settings-dropdown.blade.php b/resources/views/livewire/settings-dropdown.blade.php index efd3590985..d40d6778ee 100644 --- a/resources/views/livewire/settings-dropdown.blade.php +++ b/resources/views/livewire/settings-dropdown.blade.php @@ -103,142 +103,102 @@ return new Date(b.published_at) - new Date(a.published_at); }); } -}" @click.outside="dropdownOpen = false"> - -
    - + @else + +
    + + + +
    +
    +
    + +
    + Width
    + - @else - + + +
    + Zoom
    + - @endif - - -
    - - -
    - Appearance
    - - - - - -
    - Width
    - - - - -
    - Zoom
    - - + +
    -
    + @endif @if ($showWhatsNewModal) -
    @@ -262,8 +222,7 @@
    @if (isDev()) - + diff --git a/resources/views/livewire/source/github/change.blade.php b/resources/views/livewire/source/github/change.blade.php index 623897de54..d52e356463 100644 --- a/resources/views/livewire/source/github/change.blade.php +++ b/resources/views/livewire/source/github/change.blade.php @@ -5,7 +5,8 @@

    GitHub App

    @if (data_get($github_app, 'installation_id')) - Save + Save @endif @can('delete', $github_app) @if ($applications->count() > 0) @@ -39,170 +40,187 @@ Install Repositories on GitHub @else -
    -
    -
    - - - Sync Name - - @can('update', $github_app) - - - Rename - - - - - - Update Repositories + + +
    + + + - - @if (!isCloud()) -
    - +
    + + +
    - @if ($isSystemWide) - - System-wide GitHub Apps are shared across all teams on this Coolify instance. This means any team can use this GitHub App to deploy applications from your repositories. For better security and isolation, it's recommended to create team-specific GitHub Apps instead. - +
    + + @endif - @if (data_get($github_app, 'installation_id')) -
    -
    -
    -
    -

    Resources

    -
    -
    Here you can find all resources that are using this source.
    -
    - @if ($applications->isEmpty()) -
    - No resources are currently using this GitHub App. -
    - @else -
    -
    -
    -
    -
    - - - - - - - - - - - @foreach ($applications->sortBy('name',SORT_NATURAL) as $resource) - - - - - - - @endforeach - -
    - Project - - EnvironmentName - Type -
    - {{ data_get($resource->project(), 'name') }} - - {{ data_get($resource, 'environment.name') }} - {{ $resource->name }} - - - {{ str($resource->type())->headline() }}
    -
    -
    -
    -
    -
    - @endif -
    -
    - @endif @else

    GitHub App

    @@ -216,88 +234,137 @@ @endcan
    -
    +
    +
    @can('create', $github_app) -

    Manual Installation

    -
    - If you want to fill the form manually, you can continue below. Only for advanced users. - - Continue - -
    -

    Automated Installation

    -
    - - - - You must complete this step before you can use this source! +
    +
    +
    + + + + + Recommended + +
    +
    +

    Automated Installation

    +

    + Register a GitHub App via GitHub's manifest flow. Permissions and webhooks are pre-configured. +

    +
    +
    + @if (!isCloud() || isDev()) + +
    + + @if ($fqdn) + + @endif + @if ($ipv4) + + @endif + @if ($ipv6) + + @endif + @if (config('app.url')) + + @endif + +
    +
    + +
    + @else +
    You need to register a GitHub App before using this source.
    + @endif + +
    + + +
    +
    +
    + + Register Now + +
    +
    +
    + +
    +
    +
    + + + + + Advanced + +
    +
    +

    Manual Installation

    +

    + Fill the GitHub App form manually. For self-hosted GitHub Enterprise or custom permission setups. +

    +
    +
    + + Continue + +
    +
    +
    + @else +
    + + You don't have permission to create new GitHub Apps. Please contact your team administrator. +
    @endcan -
    -
    - @can('create', $github_app) - @if (!isCloud() || isDev()) -
    - - @if ($fqdn) - - @endif - @if ($ipv4) - - @endif - @if ($ipv6) - - @endif - @if (config('app.url')) - - @endif - - - Register Now - -
    - @else -
    -

    Register a GitHub App

    - - Register Now - -
    -
    You need to register a GitHub App before using this source.
    - @endif - -
    - - - {{-- --}} -
    - @else - - You don't have permission to create new GitHub Apps. Please contact your team administrator. - - @endcan -
    +