diff --git a/app/Actions/Application/StopApplication.php b/app/Actions/Application/StopApplication.php index b79709c5a..bfad20ccf 100644 --- a/app/Actions/Application/StopApplication.php +++ b/app/Actions/Application/StopApplication.php @@ -13,7 +13,7 @@ class StopApplication public string $jobQueue = 'high'; - public function handle(Application $application, bool $previewDeployments = false, bool $dockerCleanup = true) + public function handle(Application $application, bool $previewDeployments = false, bool $dockerCleanup = true, bool $resetRestartCount = true) { $servers = collect([$application->destination->server]); if ($application?->additional_servers?->count() > 0) { @@ -57,12 +57,17 @@ public function handle(Application $application, bool $previewDeployments = fals } } - // Reset restart tracking when application is manually stopped - $application->update([ - 'restart_count' => 0, - 'last_restart_at' => null, - 'last_restart_type' => null, - ]); + if ($resetRestartCount) { + $application->update([ + 'restart_count' => 0, + 'last_restart_at' => null, + 'last_restart_type' => null, + ]); + } else { + $application->update([ + 'status' => 'exited', + ]); + } ServiceStatusChanged::dispatch($application->environment->project->team->id); } diff --git a/app/Actions/Docker/GetContainersStatus.php b/app/Actions/Docker/GetContainersStatus.php index 5966876c6..904885dfc 100644 --- a/app/Actions/Docker/GetContainersStatus.php +++ b/app/Actions/Docker/GetContainersStatus.php @@ -2,6 +2,7 @@ namespace App\Actions\Docker; +use App\Actions\Application\StopApplication; use App\Actions\Database\StartDatabaseProxy; use App\Actions\Database\StopDatabaseProxy; use App\Actions\Shared\ComplexStatusCheck; @@ -9,6 +10,7 @@ use App\Models\ApplicationPreview; use App\Models\Server; use App\Models\ServiceDatabase; +use App\Notifications\Application\RestartLimitReached as ApplicationRestartLimitReached; use App\Services\ContainerStatusAggregator; use App\Traits\CalculatesExcludedStatus; use Illuminate\Support\Arr; @@ -464,7 +466,9 @@ public function handle(Server $server, ?Collection $containers = null, ?Collecti } // Wrap all database updates in a transaction to ensure consistency - DB::transaction(function () use ($application, $maxRestartCount, $containerStatuses) { + $restartLimitReached = false; + + DB::transaction(function () use ($application, $maxRestartCount, $containerStatuses, &$restartLimitReached) { $previousRestartCount = $application->restart_count ?? 0; if ($maxRestartCount > $previousRestartCount) { @@ -475,16 +479,10 @@ public function handle(Server $server, ?Collection $containers = null, ?Collecti 'last_restart_type' => 'crash', ]); - // Send notification - $containerName = $application->name; - $projectUuid = data_get($application, 'environment.project.uuid'); - $environmentName = data_get($application, 'environment.name'); - $applicationUuid = data_get($application, 'uuid'); - - if ($projectUuid && $applicationUuid && $environmentName) { - $url = base_url().'/project/'.$projectUuid.'/'.$environmentName.'/application/'.$applicationUuid; - } else { - $url = null; + // Check if restart limit has been reached + $maxAllowedRestarts = $application->max_restart_count ?? 0; + if ($maxAllowedRestarts > 0 && $maxRestartCount >= $maxAllowedRestarts && $previousRestartCount < $maxAllowedRestarts) { + $restartLimitReached = true; } } @@ -499,6 +497,12 @@ public function handle(Server $server, ?Collection $containers = null, ?Collecti } } }); + + if ($restartLimitReached) { + $application->refresh(); + StopApplication::dispatch($application, false, true, false); + $application->environment->project->team?->notify(new ApplicationRestartLimitReached($application)); + } } } diff --git a/app/Actions/Server/CleanupDocker.php b/app/Actions/Server/CleanupDocker.php index 33558c746..06abeb3a6 100644 --- a/app/Actions/Server/CleanupDocker.php +++ b/app/Actions/Server/CleanupDocker.php @@ -51,7 +51,7 @@ public function handle(Server $server, bool $deleteUnusedVolumes = false, bool $ 'docker container prune -f --filter "label=coolify.managed=true" --filter "label!=coolify.proxy=true" --filter "label!=coolify.type=database" --filter "label!=coolify.type=application" --filter "label!=coolify.type=service"', $imagePruneCmd, 'docker builder prune -af', - 'docker buildx prune --builder coolify-railpack -af 2>/dev/null || true', + "docker run --rm -v \$HOME/.docker/buildx:/root/.docker/buildx -v /var/run/docker.sock:/var/run/docker.sock {$helperImageWithVersion} docker buildx prune --builder coolify-railpack -af 2>/dev/null || true", "docker images --filter before=$helperImageWithVersion --filter reference=$helperImage | grep $helperImage | awk '{print $3}' | xargs -r docker rmi -f", "docker images --filter before=$realtimeImageWithVersion --filter reference=$realtimeImage | grep $realtimeImage | awk '{print $3}' | xargs -r docker rmi -f", "docker images --filter before=$helperImageWithoutPrefixVersion --filter reference=$helperImageWithoutPrefix | grep $helperImageWithoutPrefix | awk '{print $3}' | xargs -r docker rmi -f", diff --git a/app/Actions/Server/StartLogDrain.php b/app/Actions/Server/StartLogDrain.php index e4df5a061..eb419992d 100644 --- a/app/Actions/Server/StartLogDrain.php +++ b/app/Actions/Server/StartLogDrain.php @@ -3,6 +3,7 @@ namespace App\Actions\Server; use App\Models\Server; +use App\Models\Service; use Lorisleiva\Actions\Concerns\AsAction; class StartLogDrain @@ -201,10 +202,29 @@ public function handle(Server $server) "echo 'Starting Fluent Bit'", "cd $config_path && docker compose up -d", ]; + $command = array_merge($command, $this->logDrainNetworkConnectCommands($server)); return instant_remote_process($command, $server); } catch (\Throwable $e) { return handleError($e); } } + + private function logDrainNetworkConnectCommands(Server $server): array + { + if (! $server->isLogDrainEnabled()) { + return []; + } + + return $server->services() + ->with('destination') + ->where('connect_to_docker_network', true) + ->get() + ->map(fn (Service $service) => data_get($service, 'destination.network')) + ->filter() + ->unique() + ->map(fn (string $network) => 'docker network connect '.escapeshellarg($network).' coolify-log-drain >/dev/null 2>&1 || true') + ->values() + ->all(); + } } diff --git a/app/Actions/Service/StartService.php b/app/Actions/Service/StartService.php index 89817506d..463a8ad5b 100644 --- a/app/Actions/Service/StartService.php +++ b/app/Actions/Service/StartService.php @@ -50,10 +50,32 @@ public function handle(Service $service, bool $pullLatestImages = false, bool $s $commands[] = "docker network connect --alias {$serviceName}-{$service->uuid} {$safeNetwork} {$serviceName}-{$service->uuid} >/dev/null 2>&1 || true"; } } + $commands = array_merge($commands, $this->logDrainNetworkConnectCommands($service)); return remote_process($commands, $service->server, type_uuid: $service->uuid, callEventOnFinish: 'ServiceStatusChanged'); } + private function logDrainNetworkConnectCommands(Service $service): array + { + if (! data_get($service, 'connect_to_docker_network')) { + return []; + } + + if (! $service->destination?->server?->isLogDrainEnabled()) { + return []; + } + + $network = data_get($service, 'destination.network'); + + if (blank($network)) { + return []; + } + + return [ + 'docker network connect '.escapeshellarg($network).' coolify-log-drain >/dev/null 2>&1 || true', + ]; + } + private function shouldStopBeforeStarting(bool $pullLatestImages, bool $stopBeforeStart): bool { return $stopBeforeStart && ! $pullLatestImages; diff --git a/app/Casts/EncryptedArrayCast.php b/app/Casts/EncryptedArrayCast.php new file mode 100644 index 000000000..4f72c6286 --- /dev/null +++ b/app/Casts/EncryptedArrayCast.php @@ -0,0 +1,51 @@ +|null, array|null> + */ +class EncryptedArrayCast implements CastsAttributes +{ + /** + * @param array $attributes + * @return array|null + */ + public function get(Model $model, string $key, mixed $value, array $attributes): ?array + { + if ($value === null || $value === '') { + return null; + } + + try { + $value = Crypt::decryptString($value); + } catch (DecryptException) { + // Legacy plaintext JSON written before this column was encrypted. + } + + $decoded = json_decode((string) $value, true); + + return is_array($decoded) ? $decoded : null; + } + + /** + * @param array $attributes + */ + public function set(Model $model, string $key, mixed $value, array $attributes): ?string + { + if ($value === null) { + return null; + } + + return Crypt::encryptString(json_encode($value, JSON_THROW_ON_ERROR)); + } +} diff --git a/app/Console/Commands/Init.php b/app/Console/Commands/Init.php index e95c29f72..4783df072 100644 --- a/app/Console/Commands/Init.php +++ b/app/Console/Commands/Init.php @@ -253,7 +253,7 @@ private function restoreCoolifyDbBackup() 'save_s3' => false, 'frequency' => '0 0 * * *', 'database_id' => $database->id, - 'database_type' => \App\Models\StandalonePostgresql::class, + 'database_type' => StandalonePostgresql::class, 'team_id' => 0, ]); } diff --git a/app/Console/Commands/SyncBunny.php b/app/Console/Commands/SyncBunny.php index d6d77f22e..3f3e213fd 100644 --- a/app/Console/Commands/SyncBunny.php +++ b/app/Console/Commands/SyncBunny.php @@ -44,6 +44,7 @@ public function handle() $compose_file_prod = 'docker-compose.prod.yml'; $install_script = 'install.sh'; $upgrade_script = 'upgrade.sh'; + $upgrade_postgres_script = 'upgrade-postgres.sh'; $production_env = '.env.production'; $service_template = config('constants.services.file_name'); $versions = 'versions.json'; @@ -52,6 +53,7 @@ public function handle() $compose_file_prod_location = "$parent_dir/$compose_file_prod"; $install_script_location = "$parent_dir/scripts/install.sh"; $upgrade_script_location = "$parent_dir/scripts/upgrade.sh"; + $upgrade_postgres_script_location = "$parent_dir/scripts/upgrade-postgres.sh"; $production_env_location = "$parent_dir/.env.production"; $versions_location = "$parent_dir/$versions"; @@ -87,6 +89,7 @@ public function handle() $compose_file_prod_location = "$parent_dir/other/nightly/$compose_file_prod"; $production_env_location = "$parent_dir/other/nightly/$production_env"; $upgrade_script_location = "$parent_dir/other/nightly/$upgrade_script"; + $upgrade_postgres_script_location = "$parent_dir/other/nightly/$upgrade_postgres_script"; $install_script_location = "$parent_dir/other/nightly/$install_script"; $versions_location = "$parent_dir/other/nightly/$versions"; } @@ -101,6 +104,7 @@ public function handle() $compose_file_prod_location => "$bunny_cdn/$bunny_cdn_path/$compose_file_prod", $production_env_location => "$bunny_cdn/$bunny_cdn_path/$production_env", $upgrade_script_location => "$bunny_cdn/$bunny_cdn_path/$upgrade_script", + $upgrade_postgres_script_location => "$bunny_cdn/$bunny_cdn_path/$upgrade_postgres_script", $install_script_location => "$bunny_cdn/$bunny_cdn_path/$install_script", ]; @@ -215,6 +219,7 @@ public function handle() $pool->storage(fileName: "$compose_file_prod_location")->put("/$bunny_cdn_storage_name/$bunny_cdn_path/$compose_file_prod"), $pool->storage(fileName: "$production_env_location")->put("/$bunny_cdn_storage_name/$bunny_cdn_path/$production_env"), $pool->storage(fileName: "$upgrade_script_location")->put("/$bunny_cdn_storage_name/$bunny_cdn_path/$upgrade_script"), + $pool->storage(fileName: "$upgrade_postgres_script_location")->put("/$bunny_cdn_storage_name/$bunny_cdn_path/$upgrade_postgres_script"), $pool->storage(fileName: "$install_script_location")->put("/$bunny_cdn_storage_name/$bunny_cdn_path/$install_script"), ]); Http::pool(fn (Pool $pool) => [ @@ -222,6 +227,7 @@ public function handle() $pool->purge("$bunny_cdn/$bunny_cdn_path/$compose_file_prod"), $pool->purge("$bunny_cdn/$bunny_cdn_path/$production_env"), $pool->purge("$bunny_cdn/$bunny_cdn_path/$upgrade_script"), + $pool->purge("$bunny_cdn/$bunny_cdn_path/$upgrade_postgres_script"), $pool->purge("$bunny_cdn/$bunny_cdn_path/$install_script"), ]); $this->info('All files uploaded & purged to BunnyCDN.'); diff --git a/app/Console/Kernel.php b/app/Console/Kernel.php index e97105836..e6dc32383 100644 --- a/app/Console/Kernel.php +++ b/app/Console/Kernel.php @@ -8,6 +8,7 @@ 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; @@ -40,6 +41,10 @@ protected function schedule(Schedule $schedule): void $this->instanceTimezone = config('app.timezone'); } + $this->scheduleInstance->call(fn () => app(CleanupStaleMultiplexedConnections::class)->handle()) + ->name('cleanup:ssh-mux') + ->hourly() + ->when(fn () => config('constants.ssh.mux_enabled') && ! config('constants.coolify.is_windows_docker_desktop')); $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 d0bd8d3a3..907cb4456 100644 --- a/app/Helpers/SshMultiplexingHelper.php +++ b/app/Helpers/SshMultiplexingHelper.php @@ -4,6 +4,8 @@ use App\Models\PrivateKey; use App\Models\Server; +use Illuminate\Contracts\Cache\LockTimeoutException; +use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Process; @@ -23,23 +25,77 @@ public static function serverSshConfiguration(Server $server): array public static function ensureMultiplexedConnection(Server $server): bool { - return self::isMultiplexingEnabled(); + if (! self::isMultiplexingEnabled()) { + return false; + } + + if (self::connectionIsReusable($server)) { + return true; + } + + try { + return Cache::lock( + self::connectionLockKey($server), + config('constants.ssh.mux_lock_ttl') + )->block(config('constants.ssh.mux_lock_timeout'), function () use ($server) { + if (self::connectionIsReusable($server)) { + return true; + } + + if (self::masterConnectionExists($server)) { + return self::refreshMultiplexedConnection($server); + } + + return self::establishNewMultiplexedConnection($server); + }); + } catch (LockTimeoutException) { + Log::warning('SSH multiplexing lock timeout, falling back to non-multiplexed connection', [ + 'server' => $server->name ?? $server->ip, + ]); + + return false; + } catch (\Throwable $e) { + Log::warning('SSH multiplexing lock unavailable, falling back to non-multiplexed connection', [ + 'server' => $server->name ?? $server->ip, + 'error' => $e->getMessage(), + ]); + + return false; + } + } + + public static function establishNewMultiplexedConnection(Server $server): bool + { + $sshConfig = self::serverSshConfiguration($server); + $sshKeyLocation = $sshConfig['sshKeyLocation']; + $muxSocket = $sshConfig['muxFilename']; + $connectionTimeout = self::getConnectionTimeout($server); + $serverInterval = config('constants.ssh.server_interval'); + $muxPersistTime = config('constants.ssh.mux_persist_time'); + + $establishCommand = "ssh -fN -o ControlMaster=auto -o ControlPath=$muxSocket -o ControlPersist={$muxPersistTime} "; + + if (data_get($server, 'settings.is_cloudflare_tunnel')) { + $establishCommand .= ' -o ProxyCommand="cloudflared access ssh --hostname %h" '; + } + + $establishCommand .= self::getCommonSshOptions($server, $sshKeyLocation, $connectionTimeout, $serverInterval); + $establishCommand .= self::escapedUserAtHost($server); + + $establishProcess = Process::run($establishCommand); + if ($establishProcess->exitCode() !== 0) { + return false; + } + + self::storeConnectionMetadata($server); + + return true; } public static function removeMuxFile(Server $server): void { - $closeCommand = self::muxControlCommand($server, 'exit'); - Process::run($closeCommand); - } - - private static function muxControlCommand(Server $server, string $operation): string - { - $command = "ssh -O {$operation} -o ControlPath=".self::muxSocket($server).' '; - if (data_get($server, 'settings.is_cloudflare_tunnel')) { - $command .= '-o ProxyCommand="cloudflared access ssh --hostname %h" '; - } - - return $command.self::escapedUserAtHost($server); + Process::run(self::muxControlCommand($server, 'exit')); + self::clearConnectionMetadata($server); } public static function generateScpCommand(Server $server, string $source, string $dest): string @@ -53,7 +109,16 @@ public static function generateScpCommand(Server $server, string $source, string } if (self::isMultiplexingEnabled()) { - $scpCommand .= self::multiplexingOptions($server); + try { + if (self::ensureMultiplexedConnection($server)) { + $scpCommand .= self::multiplexingOptions($server); + } + } catch (\Throwable $e) { + Log::warning('SSH multiplexing failed for SCP, falling back to non-multiplexed connection', [ + 'server' => $server->name ?? $server->ip, + 'error' => $e->getMessage(), + ]); + } } if (data_get($server, 'settings.is_cloudflare_tunnel')) { @@ -84,7 +149,16 @@ public static function generateSshCommand(Server $server, string $command, bool $sshCommand = $commandTimeout > 0 ? "timeout {$commandTimeout} ssh " : 'ssh '; if (! $disableMultiplexing && self::isMultiplexingEnabled()) { - $sshCommand .= self::multiplexingOptions($server); + try { + if (self::ensureMultiplexedConnection($server)) { + $sshCommand .= self::multiplexingOptions($server); + } + } catch (\Throwable $e) { + Log::warning('SSH multiplexing failed, falling back to non-multiplexed connection', [ + 'server' => $server->name ?? $server->ip, + 'error' => $e->getMessage(), + ]); + } } if (data_get($server, 'settings.is_cloudflare_tunnel')) { @@ -101,6 +175,99 @@ public static function generateSshCommand(Server $server, string $command, bool .$delimiter; } + public static function getConnectionTimeout(Server $server): int + { + $timeout = data_get($server, 'settings.connection_timeout'); + + return is_numeric($timeout) && (int) $timeout > 0 + ? (int) $timeout + : (int) config('constants.ssh.connection_timeout'); + } + + public static function isConnectionHealthy(Server $server): bool + { + $sshConfig = self::serverSshConfiguration($server); + $muxSocket = $sshConfig['muxFilename']; + $healthCheckTimeout = config('constants.ssh.mux_health_check_timeout'); + + $healthCommand = "timeout $healthCheckTimeout ssh -o ControlMaster=auto -o ControlPath=$muxSocket "; + if (data_get($server, 'settings.is_cloudflare_tunnel')) { + $healthCommand .= '-o ProxyCommand="cloudflared access ssh --hostname %h" '; + } + $healthCommand .= self::escapedUserAtHost($server)." 'echo \"health_check_ok\"'"; + + $process = Process::run($healthCommand); + + return $process->exitCode() === 0 && str_contains($process->output(), 'health_check_ok'); + } + + public static function isConnectionExpired(Server $server): bool + { + $connectionAge = self::getConnectionAge($server); + $maxAge = config('constants.ssh.mux_max_age'); + + return $connectionAge !== null && $connectionAge > $maxAge; + } + + public static function getConnectionAge(Server $server): ?int + { + $connectionTime = Cache::get("ssh_mux_connection_time_{$server->uuid}"); + + if ($connectionTime === null) { + return null; + } + + return time() - $connectionTime; + } + + public static function refreshMultiplexedConnection(Server $server): bool + { + self::removeMuxFile($server); + + return self::establishNewMultiplexedConnection($server); + } + + private static function connectionLockKey(Server $server): string + { + return 'ssh_mux_lock_'.(gethostname() ?: 'unknown').'_'.$server->uuid; + } + + private static function masterConnectionExists(Server $server): bool + { + return Process::run(self::muxControlCommand($server, 'check'))->exitCode() === 0; + } + + private static function connectionIsReusable(Server $server): bool + { + if (! self::masterConnectionExists($server)) { + return false; + } + + if (self::getConnectionAge($server) === null) { + self::storeConnectionMetadata($server); + } + + if (self::isConnectionExpired($server)) { + return false; + } + + if (config('constants.ssh.mux_health_check_enabled') && ! self::isConnectionHealthy($server)) { + return false; + } + + return true; + } + + private static function muxControlCommand(Server $server, string $operation): string + { + $command = "ssh -O {$operation} -o ControlPath=".self::muxSocket($server).' '; + if (data_get($server, 'settings.is_cloudflare_tunnel')) { + $command .= '-o ProxyCommand="cloudflared access ssh --hostname %h" '; + } + + return $command.self::escapedUserAtHost($server); + } + private static function multiplexingOptions(Server $server): string { return '-o ControlMaster=auto ' @@ -158,15 +325,6 @@ private static function validateSshKey(PrivateKey $privateKey): void } } - public static function getConnectionTimeout(Server $server): int - { - $timeout = data_get($server, 'settings.connection_timeout'); - - return is_numeric($timeout) && (int) $timeout > 0 - ? (int) $timeout - : (int) config('constants.ssh.connection_timeout'); - } - private static function getCommonSshOptions(Server $server, string $sshKeyLocation, int $connectionTimeout, int $serverInterval, bool $isScp = false): string { $options = "-i {$sshKeyLocation} " @@ -183,4 +341,14 @@ private static function getCommonSshOptions(Server $server, string $sshKeyLocati return $options.'-p '.escapeshellarg((string) $server->port).' '; } + + private static function storeConnectionMetadata(Server $server): void + { + Cache::put("ssh_mux_connection_time_{$server->uuid}", time(), config('constants.ssh.mux_persist_time') + 300); + } + + private static function clearConnectionMetadata(Server $server): void + { + Cache::forget("ssh_mux_connection_time_{$server->uuid}"); + } } diff --git a/app/Http/Controllers/Api/ApplicationsController.php b/app/Http/Controllers/Api/ApplicationsController.php index 1f58fbc06..79830ea41 100644 --- a/app/Http/Controllers/Api/ApplicationsController.php +++ b/app/Http/Controllers/Api/ApplicationsController.php @@ -150,7 +150,7 @@ public function applications(Request $request) mediaType: 'application/json', schema: new OA\Schema( type: 'object', - required: ['project_uuid', 'server_uuid', 'environment_name', 'environment_uuid', 'git_repository', 'git_branch', 'build_pack', 'ports_exposes'], + required: ['project_uuid', 'server_uuid', 'environment_name', 'environment_uuid', 'git_repository', 'git_branch', 'build_pack'], properties: [ 'project_uuid' => ['type' => 'string', 'description' => 'The project UUID.'], 'server_uuid' => ['type' => 'string', 'description' => 'The server UUID.'], @@ -316,7 +316,7 @@ public function create_public_application(Request $request) mediaType: 'application/json', schema: new OA\Schema( type: 'object', - required: ['project_uuid', 'server_uuid', 'environment_name', 'environment_uuid', 'github_app_uuid', 'git_repository', 'git_branch', 'build_pack', 'ports_exposes'], + required: ['project_uuid', 'server_uuid', 'environment_name', 'environment_uuid', 'github_app_uuid', 'git_repository', 'git_branch', 'build_pack'], properties: [ 'project_uuid' => ['type' => 'string', 'description' => 'The project UUID.'], 'server_uuid' => ['type' => 'string', 'description' => 'The server UUID.'], @@ -482,7 +482,7 @@ public function create_private_gh_app_application(Request $request) mediaType: 'application/json', schema: new OA\Schema( type: 'object', - required: ['project_uuid', 'server_uuid', 'environment_name', 'environment_uuid', 'private_key_uuid', 'git_repository', 'git_branch', 'build_pack', 'ports_exposes'], + required: ['project_uuid', 'server_uuid', 'environment_name', 'environment_uuid', 'private_key_uuid', 'git_repository', 'git_branch', 'build_pack'], properties: [ 'project_uuid' => ['type' => 'string', 'description' => 'The project UUID.'], 'server_uuid' => ['type' => 'string', 'description' => 'The server UUID.'], @@ -785,7 +785,7 @@ public function create_dockerfile_application(Request $request) mediaType: 'application/json', schema: new OA\Schema( type: 'object', - required: ['project_uuid', 'server_uuid', 'environment_name', 'environment_uuid', 'docker_registry_image_name', 'ports_exposes'], + required: ['project_uuid', 'server_uuid', 'environment_name', 'environment_uuid', 'docker_registry_image_name'], properties: [ 'project_uuid' => ['type' => 'string', 'description' => 'The project UUID.'], 'server_uuid' => ['type' => 'string', 'description' => 'The server UUID.'], @@ -1028,7 +1028,7 @@ private function create_application(Request $request, $type) 'git_repository' => ['string', 'required', new ValidGitRepositoryUrl], 'git_branch' => ['string', 'required', new ValidGitBranch], 'build_pack' => ['required', Rule::enum(BuildPackTypes::class)], - 'ports_exposes' => 'string|regex:/^(\d+)(,\d+)*$/|required', + 'ports_exposes' => 'string|regex:/^(\d+)(,\d+)*$/|nullable', 'docker_compose_domains' => 'array|nullable', 'docker_compose_domains.*' => 'array:name,domain', 'docker_compose_domains.*.name' => 'string|required', @@ -1234,7 +1234,7 @@ private function create_application(Request $request, $type) 'git_repository' => 'string|required', 'git_branch' => ['string', 'required', new ValidGitBranch], 'build_pack' => ['required', Rule::enum(BuildPackTypes::class)], - 'ports_exposes' => 'string|regex:/^(\d+)(,\d+)*$/|required', + 'ports_exposes' => 'string|regex:/^(\d+)(,\d+)*$/|nullable', 'github_app_uuid' => 'string|required', 'watch_paths' => 'string|nullable', 'docker_compose_domains' => 'array|nullable', @@ -1474,7 +1474,7 @@ private function create_application(Request $request, $type) 'git_repository' => ['string', 'required', new ValidGitRepositoryUrl], 'git_branch' => ['string', 'required', new ValidGitBranch], 'build_pack' => ['required', Rule::enum(BuildPackTypes::class)], - 'ports_exposes' => 'string|regex:/^(\d+)(,\d+)*$/|required', + 'ports_exposes' => 'string|regex:/^(\d+)(,\d+)*$/|nullable', 'private_key_uuid' => 'string|required', 'watch_paths' => 'string|nullable', 'docker_compose_domains' => 'array|nullable', @@ -1797,7 +1797,7 @@ private function create_application(Request $request, $type) $validationRules = [ 'docker_registry_image_name' => ['required', 'string', 'max:255', new DockerImageFormat], 'docker_registry_image_tag' => ValidationPatterns::dockerImageTagRules(), - 'ports_exposes' => 'string|regex:/^(\d+)(,\d+)*$/|required', + 'ports_exposes' => 'string|regex:/^(\d+)(,\d+)*$/|nullable', ]; $validationRules = array_merge(sharedDataApplications(), $validationRules); $validator = customApiValidator($request->all(), $validationRules); diff --git a/app/Http/Controllers/Api/ServersController.php b/app/Http/Controllers/Api/ServersController.php index 30cafad3f..f4efc8577 100644 --- a/app/Http/Controllers/Api/ServersController.php +++ b/app/Http/Controllers/Api/ServersController.php @@ -13,6 +13,7 @@ use App\Models\Project; use App\Models\Server as ModelsServer; use App\Rules\ValidServerIp; +use App\Support\ValidationPatterns; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use OpenApi\Attributes as OA; @@ -489,10 +490,12 @@ public function create_server(Request $request) 'ip' => ['string', 'required', new ValidServerIp], 'port' => 'integer|nullable|between:1,65535', 'private_key_uuid' => 'string|required', - 'user' => ['string', 'nullable', 'regex:/^[a-zA-Z0-9_-]+$/'], + 'user' => ValidationPatterns::serverUsernameRules(required: false), 'is_build_server' => 'boolean|nullable', 'instant_validate' => 'boolean|nullable', 'proxy_type' => 'string|nullable', + ], [ + ...ValidationPatterns::serverUsernameMessages(), ]); $extraFields = array_diff(array_keys($request->all()), $allowedFields); @@ -668,7 +671,7 @@ public function update_server(Request $request) 'ip' => ['string', 'nullable', new ValidServerIp], 'port' => 'integer|nullable|between:1,65535', 'private_key_uuid' => 'string|nullable', - 'user' => ['string', 'nullable', 'regex:/^[a-zA-Z0-9_-]+$/'], + 'user' => ValidationPatterns::serverUsernameRules(required: false), 'is_build_server' => 'boolean|nullable', 'instant_validate' => 'boolean|nullable', 'proxy_type' => 'string|nullable', @@ -678,6 +681,8 @@ public function update_server(Request $request) 'server_disk_usage_notification_threshold' => 'integer|min:1|max:100', 'server_disk_usage_check_frequency' => 'string', 'connection_timeout' => 'integer|min:1|max:300', + ], [ + ...ValidationPatterns::serverUsernameMessages(), ]); $extraFields = array_diff(array_keys($request->all()), $allowedFields); @@ -703,17 +708,17 @@ public function update_server(Request $request) $validProxyTypes = collect(ProxyTypes::cases())->map(function ($proxyType) { return str($proxyType->value)->lower(); }); - if ($validProxyTypes->contains(str($request->proxy_type)->lower())) { - $server->changeProxy($request->proxy_type, async: true); - } else { + if (! $validProxyTypes->contains(str($request->proxy_type)->lower())) { return response()->json(['message' => 'Invalid proxy type.'], 422); } } - $server->update($request->only(['name', 'description', 'ip', 'port', 'user'])); - if ($request->is_build_server) { - $server->settings()->update([ - 'is_build_server' => $request->is_build_server, - ]); + $updateFields = $request->only(['name', 'description', 'ip', 'port', 'user']); + if ($request->filled('private_key_uuid')) { + $privateKey = PrivateKey::whereTeamId($teamId)->whereUuid($request->private_key_uuid)->first(); + if (! $privateKey) { + return response()->json(['message' => 'Private key not found.'], 404); + } + $updateFields['private_key_id'] = $privateKey->id; } if ($request->has('server_disk_usage_check_frequency') && ! validate_cron_expression($request->server_disk_usage_check_frequency)) { @@ -723,11 +728,22 @@ public function update_server(Request $request) ], 422); } + $server->update($updateFields); + if ($request->has('is_build_server')) { + $server->settings()->update([ + 'is_build_server' => $request->boolean('is_build_server'), + ]); + } + $advancedSettings = $request->only(['concurrent_builds', 'dynamic_timeout', 'deployment_queue_limit', 'server_disk_usage_notification_threshold', 'server_disk_usage_check_frequency', 'connection_timeout']); if (! empty($advancedSettings)) { $server->settings()->update(array_filter($advancedSettings, fn ($value) => ! is_null($value))); } + if ($request->proxy_type) { + $server->changeProxy($request->proxy_type, async: true); + } + if ($request->instant_validate) { ValidateServer::dispatch($server); } diff --git a/app/Http/Controllers/Controller.php b/app/Http/Controllers/Controller.php index 6ce6b6d57..3090538c3 100644 --- a/app/Http/Controllers/Controller.php +++ b/app/Http/Controllers/Controller.php @@ -7,6 +7,7 @@ use App\Models\User; use App\Providers\RouteServiceProvider; use Illuminate\Auth\Events\Verified; +use Illuminate\Contracts\Encryption\DecryptException; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Foundation\Validation\ValidatesRequests; use Illuminate\Http\Request; @@ -98,23 +99,50 @@ public function link() { $token = request()->get('token'); if ($token) { - $decrypted = Crypt::decryptString($token); - $email = str($decrypted)->before('@@@'); - $password = str($decrypted)->after('@@@'); + try { + $decrypted = Crypt::decryptString($token); + } catch (DecryptException) { + return redirect()->route('login')->with('error', 'Invalid credentials.'); + } + + if (! str_contains($decrypted, '@@@')) { + return redirect()->route('login')->with('error', 'Invalid credentials.'); + } + + $payload = explode('@@@', $decrypted, 3); + if (count($payload) === 3) { + [$email, $invitationUuid, $password] = $payload; + } else { + [$email, $password] = $payload; + $invitationUuid = null; + } + + $email = Str::lower($email); $user = User::whereEmail($email)->first(); if (! $user) { return redirect()->route('login'); } + + $invitation = TeamInvitation::query() + ->where('email', $email) + ->when($invitationUuid, fn ($query) => $query->where('uuid', $invitationUuid)) + ->where('link', request()->fullUrl()) + ->first(); + if (! $invitation || ! $invitation->isValid()) { + return redirect()->route('login')->with('error', 'Invitation has expired or been revoked.'); + } + if (Hash::check($password, $user->password)) { - $invitation = TeamInvitation::whereEmail($email); - if ($invitation->exists()) { - $team = $invitation->first()->team; - $user->teams()->attach($team->id, ['role' => $invitation->first()->role]); - $invitation->delete(); - } else { - $team = $user->teams()->first(); + $team = $invitation->team; + if (! $user->teams()->where('team_id', $team->id)->exists()) { + $user->teams()->attach($team->id, ['role' => $invitation->role]); } + $invitation->delete(); + Auth::login($user); + $user->forceFill([ + 'password' => Hash::make(Str::random(64)), + ])->save(); session(['currentTeam' => $team]); return redirect()->route('dashboard'); diff --git a/app/Http/Controllers/Webhook/Github.php b/app/Http/Controllers/Webhook/Github.php index 8e3ffcd7c..40c5cbdf0 100644 --- a/app/Http/Controllers/Webhook/Github.php +++ b/app/Http/Controllers/Webhook/Github.php @@ -11,6 +11,8 @@ use App\Models\GithubApp; use App\Models\PrivateKey; use Exception; +use Illuminate\Http\Exceptions\HttpResponseException; +use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Http; @@ -539,19 +541,22 @@ public function redirect(Request $request) public function install(Request $request) { - $source = (string) $request->query('source', ''); - abort_if(blank($source), 404); - - $github_app = GithubApp::ownedByCurrentTeam()->where('uuid', $source)->firstOrFail(); - $setup_action = (string) $request->query('setup_action', ''); - if ($setup_action !== 'install') { - return redirect()->route('source.github.show', ['github_app_uuid' => $github_app->uuid]); - } + abort_unless(in_array($setup_action, ['install', 'update'], true), 422, 'Invalid GitHub App setup action.'); $installation_id = (string) $request->query('installation_id', ''); abort_unless(ctype_digit($installation_id), 422, 'Missing GitHub App installation id.'); + if ($setup_action === 'update') { + return $this->redirectAfterGithubAppInstallationUpdate($installation_id); + } + + $github_app = $this->consumeGithubAppSetupState( + request: $request, + state: (string) $request->query('state', ''), + action: 'install', + ); + abort_unless( $this->githubInstallationBelongsToApp($github_app, $installation_id), 403, @@ -564,6 +569,19 @@ public function install(Request $request) return redirect()->route('source.github.show', ['github_app_uuid' => $github_app->uuid]); } + private function redirectAfterGithubAppInstallationUpdate(string $installation_id): RedirectResponse + { + $github_app = GithubApp::ownedByCurrentTeam() + ->where('installation_id', $installation_id) + ->first(); + + if ($github_app) { + return redirect()->route('source.github.show', ['github_app_uuid' => $github_app->uuid]); + } + + return redirect()->route('source.all'); + } + /** * Verify that the given installation id actually belongs to this GitHub App. * @@ -596,11 +614,14 @@ private function githubInstallationBelongsToApp(GithubApp $github_app, string $i private function consumeGithubAppSetupState(Request $request, string $state, string $action): GithubApp { - abort_if(blank($state), 404); + if (blank($state)) { + $this->rejectInvalidGithubAppSetupState($request); + } $payload = Cache::pull($this->githubAppSetupStateCacheKey($state)); - abort_unless(is_array($payload), 404); - abort_unless(data_get($payload, 'action') === $action, 404); + if (! is_array($payload) || data_get($payload, 'action') !== $action) { + $this->rejectInvalidGithubAppSetupState($request); + } $team_id = $request->user()?->currentTeam()?->id; abort_unless(! is_null($team_id) && (int) data_get($payload, 'team_id') === $team_id, 403); @@ -610,6 +631,18 @@ private function consumeGithubAppSetupState(Request $request, string $state, str ->firstOrFail(); } + private function rejectInvalidGithubAppSetupState(Request $request): never + { + if ($request->expectsJson()) { + abort(404); + } + + throw new HttpResponseException( + redirect() + ->route('source.all') + ); + } + private function githubAppSetupStateCacheKey(string $state): string { return 'github-app-setup-state:'.hash('sha256', $state); diff --git a/app/Jobs/ApplicationDeploymentJob.php b/app/Jobs/ApplicationDeploymentJob.php index a1478aaff..1b8ef3fc4 100644 --- a/app/Jobs/ApplicationDeploymentJob.php +++ b/app/Jobs/ApplicationDeploymentJob.php @@ -1388,7 +1388,7 @@ private function generate_runtime_environment_variables() // Add PORT if not exists, use the first port as default if ($this->build_pack !== 'dockercompose') { - if ($this->application->environment_variables->where('key', 'PORT')->isEmpty()) { + if ($this->application->environment_variables->where('key', 'PORT')->isEmpty() && ! empty($ports)) { $envs->push("PORT={$ports[0]}"); } } @@ -2129,21 +2129,23 @@ private function prepare_builder_image(bool $firstTry = true) $helperImage = "{$helperImage}:".getHelperVersion(); // Get user home directory $this->serverUserHomeDir = instant_remote_process(['echo $HOME'], $this->server); + instant_remote_process(["mkdir -p {$this->serverUserHomeDir}/.docker/buildx"], $this->server); $this->dockerConfigFileExists = instant_remote_process(["test -f {$this->serverUserHomeDir}/.docker/config.json && echo 'OK' || echo 'NOK'"], $this->server); $env_flags = $this->generate_docker_env_flags_for_secrets(); + $buildxMetadataVolume = "-v {$this->serverUserHomeDir}/.docker/buildx:/root/.docker/buildx"; if ($this->use_build_server) { if ($this->dockerConfigFileExists === 'NOK') { throw new DeploymentException('Docker config file (~/.docker/config.json) not found on the build server. Please run "docker login" to login to the docker registry on the server.'); } - $runCommand = "docker run -d --name {$this->deployment_uuid} {$env_flags} --rm -v {$this->serverUserHomeDir}/.docker/config.json:/root/.docker/config.json:ro -v /var/run/docker.sock:/var/run/docker.sock {$helperImage}"; + $runCommand = "docker run -d --name {$this->deployment_uuid} {$env_flags} --rm -v {$this->serverUserHomeDir}/.docker/config.json:/root/.docker/config.json:ro {$buildxMetadataVolume} -v /var/run/docker.sock:/var/run/docker.sock {$helperImage}"; } else { if ($this->dockerConfigFileExists === 'OK') { $safeNetwork = escapeshellarg($this->destination->network); - $runCommand = "docker run -d --network {$safeNetwork} --name {$this->deployment_uuid} {$env_flags} --rm -v {$this->serverUserHomeDir}/.docker/config.json:/root/.docker/config.json:ro -v /var/run/docker.sock:/var/run/docker.sock {$helperImage}"; + $runCommand = "docker run -d --network {$safeNetwork} --name {$this->deployment_uuid} {$env_flags} --rm -v {$this->serverUserHomeDir}/.docker/config.json:/root/.docker/config.json:ro {$buildxMetadataVolume} -v /var/run/docker.sock:/var/run/docker.sock {$helperImage}"; } else { $safeNetwork = escapeshellarg($this->destination->network); - $runCommand = "docker run -d --network {$safeNetwork} --name {$this->deployment_uuid} {$env_flags} --rm -v /var/run/docker.sock:/var/run/docker.sock {$helperImage}"; + $runCommand = "docker run -d --network {$safeNetwork} --name {$this->deployment_uuid} {$env_flags} --rm {$buildxMetadataVolume} -v /var/run/docker.sock:/var/run/docker.sock {$helperImage}"; } } if ($firstTry) { @@ -2258,7 +2260,7 @@ private function gitLsRemoteCommand(string $lsRemoteRef, ?string $identityFile = $sshCommand = "ssh -o ConnectTimeout=30 -p {$this->customPort} -o Port={$this->customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null"; if ($identityFile !== null) { - $sshCommand .= " -i {$identityFile}"; + $sshCommand .= " -i {$identityFile} -o IdentitiesOnly=yes"; } return 'GIT_SSH_COMMAND="'.$sshCommand.'" git ls-remote '.escapeshellarg($this->fullRepoUrl).' '.escapeshellarg($lsRemoteRef); @@ -2298,18 +2300,19 @@ private function check_git_if_build_needed() $private_key = data_get($this->application, 'private_key.private_key'); if ($private_key) { $private_key = base64_encode($private_key); + $customSshKeyLocation = "/root/.ssh/id_rsa_coolify_{$this->deployment_uuid}"; $this->execute_remote_command( [ executeInDocker($this->deployment_uuid, 'mkdir -p /root/.ssh'), ], [ - executeInDocker($this->deployment_uuid, "echo '{$private_key}' | base64 -d | tee /root/.ssh/id_rsa > /dev/null"), + executeInDocker($this->deployment_uuid, "echo '{$private_key}' | base64 -d | tee {$customSshKeyLocation} > /dev/null"), ], [ - executeInDocker($this->deployment_uuid, 'chmod 600 /root/.ssh/id_rsa'), + executeInDocker($this->deployment_uuid, "chmod 600 {$customSshKeyLocation}"), ], [ - executeInDocker($this->deployment_uuid, $this->gitLsRemoteCommand($lsRemoteRef, '/root/.ssh/id_rsa')), + executeInDocker($this->deployment_uuid, $this->gitLsRemoteCommand($lsRemoteRef, $customSshKeyLocation)), 'hidden' => true, 'save' => 'git_commit_sha', ] @@ -3136,7 +3139,7 @@ private function generate_compose_file() 'image' => $this->production_image_name, 'container_name' => $this->container_name, 'restart' => RESTART_MODE, - 'expose' => $ports, + ...(! empty($ports) ? ['expose' => $ports] : []), 'networks' => [ $this->destination->network => [ 'aliases' => array_merge( @@ -3168,16 +3171,19 @@ private function generate_compose_file() // If custom_healthcheck_found is true, the Dockerfile's HEALTHCHECK will be used // If healthcheck is disabled, no healthcheck will be added if (! $this->application->custom_healthcheck_found && ! $this->application->isHealthcheckDisabled()) { - $docker_compose['services'][$this->container_name]['healthcheck'] = [ - 'test' => [ - 'CMD-SHELL', - $this->generate_healthcheck_commands(), - ], - 'interval' => $this->application->health_check_interval.'s', - 'timeout' => $this->application->health_check_timeout.'s', - 'retries' => $this->application->health_check_retries, - 'start_period' => $this->application->health_check_start_period.'s', - ]; + $healthcheck_command = $this->generate_healthcheck_commands(); + if ($healthcheck_command !== null) { + $docker_compose['services'][$this->container_name]['healthcheck'] = [ + 'test' => [ + 'CMD-SHELL', + $healthcheck_command, + ], + 'interval' => $this->application->health_check_interval.'s', + 'timeout' => $this->application->health_check_timeout.'s', + 'retries' => $this->application->health_check_retries, + 'start_period' => $this->application->health_check_start_period.'s', + ]; + } } if (! is_null($this->application->limits_cpuset)) { @@ -3387,7 +3393,11 @@ private function generate_healthcheck_commands() // HTTP type healthcheck (default) if (! $this->application->health_check_port) { - $health_check_port = (int) $this->application->ports_exposes_array[0]; + if (! empty($this->application->ports_exposes_array)) { + $health_check_port = (int) $this->application->ports_exposes_array[0]; + } else { + return null; + } } else { $health_check_port = (int) $this->application->health_check_port; } diff --git a/app/Jobs/CleanupStaleMultiplexedConnections.php b/app/Jobs/CleanupStaleMultiplexedConnections.php new file mode 100644 index 000000000..0d3029c66 --- /dev/null +++ b/app/Jobs/CleanupStaleMultiplexedConnections.php @@ -0,0 +1,228 @@ +cleanupStaleConnections(); + $this->cleanupNonExistentServerConnections(); + $this->cleanupOrphanedSshProcesses(); + $this->cleanupOrphanedCloudflaredProcesses(); + } + + /** + * 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) { + // Backgrounded ssh master: current `ssh -fN` or legacy `ssh -fNM`. + if (! preg_match('#(^|/)ssh -fN#', $process['args'])) { + continue; + } + + // Only ever touch ssh processes pointing at Coolify's mux directory. + if (! preg_match('#ControlPath=('.preg_quote($muxDir, '#').'/\S+)#', $process['args'], $pathMatch)) { + continue; + } + + if ($process['etimes'] >= $minAge && ! file_exists($pathMatch[1])) { + $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; + } + + 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/SendWebhookJob.php b/app/Jobs/SendWebhookJob.php index 9d2a94606..17517cebb 100644 --- a/app/Jobs/SendWebhookJob.php +++ b/app/Jobs/SendWebhookJob.php @@ -2,6 +2,7 @@ namespace App\Jobs; +use App\Rules\SafeWebhookUrl; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldBeEncrypted; use Illuminate\Contracts\Queue\ShouldQueue; @@ -44,7 +45,7 @@ public function handle(): void { $validator = Validator::make( ['webhook_url' => $this->webhookUrl], - ['webhook_url' => ['required', 'url', new \App\Rules\SafeWebhookUrl]] + ['webhook_url' => ['required', 'url', new SafeWebhookUrl]] ); if ($validator->fails()) { diff --git a/app/Livewire/Boarding/Index.php b/app/Livewire/Boarding/Index.php index b16f1abde..d57c06952 100644 --- a/app/Livewire/Boarding/Index.php +++ b/app/Livewire/Boarding/Index.php @@ -8,6 +8,7 @@ use App\Models\Server; use App\Models\Team; use App\Services\ConfigurationRepository; +use App\Support\ValidationPatterns; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Support\Collection; use Livewire\Attributes\Url; @@ -218,6 +219,23 @@ private function updateServerDetails() } } + protected function rules(): array + { + return [ + 'remoteServerName' => 'required|string', + 'remoteServerHost' => 'required|string', + 'remoteServerPort' => 'required|integer|min:1|max:65535', + 'remoteServerUser' => ValidationPatterns::serverUsernameRules(), + ]; + } + + protected function messages(): array + { + return [ + ...ValidationPatterns::serverUsernameMessages('remoteServerUser', 'SSH User'), + ]; + } + public function getProxyType() { $this->selectProxy(ProxyTypes::TRAEFIK->value); @@ -281,12 +299,7 @@ public function savePrivateKey() public function saveServer() { - $this->validate([ - 'remoteServerName' => 'required|string', - 'remoteServerHost' => 'required|string', - 'remoteServerPort' => 'required|integer', - 'remoteServerUser' => 'required|string', - ]); + $this->validate(); try { $this->authorize('create', Server::class); @@ -478,10 +491,10 @@ public function showNewResource() public function saveAndValidateServer() { - $this->validate([ - 'remoteServerPort' => 'required|integer|min:1|max:65535', - 'remoteServerUser' => 'required|string', - ]); + $this->validate(array_intersect_key($this->rules(), array_flip([ + 'remoteServerPort', + 'remoteServerUser', + ]))); $this->createdServer->update([ 'port' => $this->remoteServerPort, diff --git a/app/Livewire/Destination/Resources.php b/app/Livewire/Destination/Resources.php new file mode 100644 index 000000000..c71010411 --- /dev/null +++ b/app/Livewire/Destination/Resources.php @@ -0,0 +1,125 @@ +route('destination.index'); + } + if (! $destination instanceof StandaloneDocker) { + return redirect()->route('destination.show', ['destination_uuid' => $destination->uuid]); + } + + $this->destination = $destination; + $this->loadResources(); + } catch (\Throwable $e) { + return handleError($e, $this); + } + } + + /** + * Load applications, services, and database resources deployed to the standalone Docker destination. + * + * @return void Populates the resources property for display. + */ + public function loadResources(): void + { + $this->resources = $this->collectResources([ + $this->destination->applications, + $this->destination->services, + $this->destination->postgresqls, + $this->destination->redis, + $this->destination->mongodbs, + $this->destination->mysqls, + $this->destination->mariadbs, + $this->destination->keydbs, + $this->destination->dragonflies, + $this->destination->clickhouses, + ]); + } + + /** + * @param array> $groups + * @return array + */ + protected function collectResources(array $groups): array + { + $rows = []; + foreach ($groups as $group) { + foreach ($group as $resource) { + $rows[] = $this->resourceRow($resource); + } + } + + return $rows; + } + + /** + * @param Application|Service|StandalonePostgresql|StandaloneRedis|StandaloneMongodb|StandaloneMysql|StandaloneMariadb|StandaloneKeydb|StandaloneDragonfly|StandaloneClickhouse $resource + * @return array{uuid:string,type:string,name:string,project:string|null,environment:string|null,url:string|null,search:string} + */ + protected function resourceRow(BaseModel $resource): array + { + $type = match (true) { + $resource instanceof Application => 'application', + $resource instanceof Service => 'service', + default => 'database', + }; + $environment = $resource->environment; + $project = $environment?->project; + $routeName = "project.{$type}.configuration"; + $url = ($project && $environment) + ? route($routeName, [ + 'project_uuid' => $project->uuid, + 'environment_uuid' => $environment->uuid, + "{$type}_uuid" => $resource->uuid, + ]) + : null; + + return [ + 'uuid' => $resource->uuid, + 'type' => $type, + 'name' => $resource->name, + 'project' => $project?->name, + 'environment' => $environment?->name, + 'url' => $url, + 'search' => strtolower(implode(' ', array_filter([ + $type, + $resource->name, + $project?->name, + $environment?->name, + ]))), + ]; + } + + public function render(): View + { + return view('livewire.destination.resources'); + } +} diff --git a/app/Livewire/Project/Application/Advanced.php b/app/Livewire/Project/Application/Advanced.php index 947368a0d..f62f8bfdd 100644 --- a/app/Livewire/Project/Application/Advanced.php +++ b/app/Livewire/Project/Application/Advanced.php @@ -87,6 +87,9 @@ class Advanced extends Component #[Validate(['boolean'])] public bool $isConnectToDockerNetworkEnabled = false; + #[Validate(['integer', 'min:0'])] + public int $maxRestartCount = 10; + public function mount() { try { @@ -149,6 +152,7 @@ public function syncData(bool $toModel = false) $this->disableBuildCache = $this->application->settings->disable_build_cache; $this->injectBuildArgsToDockerfile = $this->application->settings->inject_build_args_to_dockerfile ?? true; $this->includeSourceCommitInBuild = $this->application->settings->include_source_commit_in_build ?? false; + $this->maxRestartCount = $this->application->max_restart_count ?? 10; } // Load stop_grace_period separately since it has its own save handler @@ -289,6 +293,21 @@ public function saveStopGracePeriod() } } + public function saveMaxRestartCount() + { + try { + $this->authorize('update', $this->application); + $this->validate([ + 'maxRestartCount' => 'integer|min:0', + ]); + $this->application->max_restart_count = $this->maxRestartCount; + $this->application->save(); + $this->dispatch('success', 'Max restart count saved.'); + } catch (\Throwable $e) { + return handleError($e, $this); + } + } + public function render() { return view('livewire.project.application.advanced'); diff --git a/app/Livewire/Project/Application/Configuration.php b/app/Livewire/Project/Application/Configuration.php index 887fff35a..fb069f65b 100644 --- a/app/Livewire/Project/Application/Configuration.php +++ b/app/Livewire/Project/Application/Configuration.php @@ -28,7 +28,7 @@ public function mount() $project = currentTeam() ->projects() - ->select('id', 'uuid', 'team_id') + ->select('id', 'uuid', 'name', 'team_id') ->where('uuid', request()->route('project_uuid')) ->firstOrFail(); $environment = $project->environments() diff --git a/app/Livewire/Project/Application/General.php b/app/Livewire/Project/Application/General.php index 0295aa5fc..89b1b4217 100644 --- a/app/Livewire/Project/Application/General.php +++ b/app/Livewire/Project/Application/General.php @@ -154,7 +154,7 @@ protected function rules(): array 'staticImage' => 'required', 'baseDirectory' => array_merge(['required'], array_slice(ValidationPatterns::directoryPathRules(), 1)), 'publishDirectory' => ValidationPatterns::directoryPathRules(), - 'portsExposes' => ['required', 'string', 'regex:/^(\d+)(,\d+)*$/'], + 'portsExposes' => ['nullable', 'string', 'regex:/^(\d+)(,\d+)*$/'], 'portsMappings' => ValidationPatterns::portMappingRules(), 'customNetworkAliases' => 'nullable', 'dockerfile' => 'nullable', @@ -212,7 +212,6 @@ protected function messages(): array 'buildPack.required' => 'The Build Pack field is required.', 'staticImage.required' => 'The Static Image field is required.', 'baseDirectory.required' => 'The Base Directory field is required.', - 'portsExposes.required' => 'The Exposed Ports field is required.', 'portsExposes.regex' => 'Ports exposes must be a comma-separated list of port numbers (e.g. 3000,3001).', ...ValidationPatterns::portMappingMessages(), 'isStatic.required' => 'The Static setting is required.', @@ -760,7 +759,7 @@ public function submit($showToaster = true) $this->resetErrorBag(); - $this->portsExposes = str($this->portsExposes)->replace(' ', '')->trim()->toString(); + $this->portsExposes = str($this->portsExposes)->replace(' ', '')->trim()->toString() ?: null; if ($this->portsMappings) { $this->portsMappings = str($this->portsMappings)->replace(' ', '')->trim()->toString(); } diff --git a/app/Livewire/Project/Database/Configuration.php b/app/Livewire/Project/Database/Configuration.php index 9f952ff2b..7f58a8421 100644 --- a/app/Livewire/Project/Database/Configuration.php +++ b/app/Livewire/Project/Database/Configuration.php @@ -26,7 +26,7 @@ public function mount() $project = currentTeam() ->projects() - ->select('id', 'uuid', 'team_id') + ->select('id', 'uuid', 'name', 'team_id') ->where('uuid', request()->route('project_uuid')) ->firstOrFail(); $environment = $project->environments() diff --git a/app/Livewire/Project/New/Select.php b/app/Livewire/Project/New/Select.php index 165e4b59e..d6d234b18 100644 --- a/app/Livewire/Project/New/Select.php +++ b/app/Livewire/Project/New/Select.php @@ -4,7 +4,9 @@ use App\Models\Project; use App\Models\Server; +use Carbon\CarbonImmutable; use Illuminate\Support\Collection; +use Illuminate\Support\Facades\Cache; use Livewire\Component; class Select extends Component @@ -105,7 +107,9 @@ public function updatedSelectedEnvironment() public function loadServices() { $services = get_service_templates(); - $services = collect($services)->map(function ($service, $key) { + $templateLastUpdatedMap = $this->serviceTemplateLastUpdatedMap($services->keys()); + + $services = collect($services)->map(function ($service, $key) use ($templateLastUpdatedMap) { $default_logo = 'images/default.webp'; $logo = data_get($service, 'logo', $default_logo); $local_logo_path = public_path($logo); @@ -116,6 +120,7 @@ public function loadServices() 'logo_github_url' => file_exists($local_logo_path) ? 'https://raw.githubusercontent.com/coollabsio/coolify/refs/heads/main/public/'.$logo : asset($default_logo), + 'templateLastUpdated' => $templateLastUpdatedMap[(string) $key] ?? null, ] + (array) $service; })->all(); @@ -247,6 +252,7 @@ public function loadServices() ]; return [ + 'serviceTemplatesLastUpdated' => $this->serviceTemplatesLastUpdated(), 'services' => $services, 'categories' => $categories, 'gitBasedApplications' => $gitBasedApplications, @@ -268,6 +274,55 @@ public function instantSave() } } + private function serviceTemplatesLastUpdated(): ?string + { + return $this->formatLastModified($this->serviceTemplatesPath()); + } + + private function serviceTemplateLastUpdatedMap(Collection $serviceNames): array + { + $bundleMtime = file_exists($this->serviceTemplatesPath()) ? filemtime($this->serviceTemplatesPath()) : 0; + + return Cache::remember( + "service-template-last-updated-map:{$bundleMtime}", + now()->addDay(), + fn () => $serviceNames + ->mapWithKeys(fn ($serviceName) => [ + (string) $serviceName => $this->serviceTemplateLastUpdated((string) $serviceName), + ]) + ->all() + ); + } + + private function serviceTemplateLastUpdated(string $serviceName): ?string + { + foreach (['yaml', 'yml'] as $extension) { + $templatePath = base_path("templates/compose/{$serviceName}.{$extension}"); + + if (file_exists($templatePath)) { + return $this->formatLastModified($templatePath); + } + } + + return null; + } + + private function serviceTemplatesPath(): string + { + return base_path('templates/'.config('constants.services.file_name')); + } + + private function formatLastModified(string $path): ?string + { + if (! file_exists($path)) { + return null; + } + + return CarbonImmutable::createFromTimestamp(filemtime($path)) + ->timezone(config('app.timezone')) + ->format('M j, Y H:i'); + } + public function setType(string $type) { $type = str($type)->lower()->slug()->value(); diff --git a/app/Livewire/Project/Service/Configuration.php b/app/Livewire/Project/Service/Configuration.php index ac2b39bb8..caa19042b 100644 --- a/app/Livewire/Project/Service/Configuration.php +++ b/app/Livewire/Project/Service/Configuration.php @@ -44,7 +44,7 @@ public function mount() $this->query = request()->query(); $project = currentTeam() ->projects() - ->select('id', 'uuid', 'team_id') + ->select('id', 'uuid', 'name', 'team_id') ->where('uuid', request()->route('project_uuid')) ->firstOrFail(); $environment = $project->environments() diff --git a/app/Livewire/Project/Shared/ConfigurationChecker.php b/app/Livewire/Project/Shared/ConfigurationChecker.php index d583e74e6..43bf3140b 100644 --- a/app/Livewire/Project/Shared/ConfigurationChecker.php +++ b/app/Livewire/Project/Shared/ConfigurationChecker.php @@ -21,8 +21,6 @@ class ConfigurationChecker extends Component public array $configurationDiff = []; - public array $groupedConfigurationChanges = []; - public Application|Service|StandaloneRedis|StandalonePostgresql|StandaloneMongodb|StandaloneMysql|StandaloneMariadb|StandaloneKeydb|StandaloneDragonfly|StandaloneClickhouse $resource; public function getListeners(): array @@ -50,21 +48,56 @@ public function refreshConfigurationChanges(): void $this->configurationChanged(); } + /** + * Members must never see environment variable values, so redact every + * environment-section change before it is serialized to the browser. + * + * @param array> $changes + * @return array> + */ + private function redactEnvironmentChanges(array $changes, bool $redact): array + { + if (! $redact) { + return $changes; + } + + return collect($changes) + ->map(function (array $change): array { + if (data_get($change, 'section') !== 'environment') { + return $change; + } + + $change['old_display_value'] = data_get($change, 'old_display_value') === '-' ? '-' : '••••••••'; + $change['new_display_value'] = data_get($change, 'new_display_value') === '-' ? '-' : '••••••••'; + $change['old_full_value'] = null; + $change['new_full_value'] = null; + $change['expandable'] = false; + $change['display_summary'] = data_get($change, 'type') === 'changed' ? 'Changed' : null; + + return $change; + }) + ->all(); + } + public function configurationChanged(): void { $this->resource->refresh(); if ($this->resource instanceof Application) { $diff = $this->resource->pendingDeploymentConfigurationDiff(); + // Fail closed: only owners/admins may see unlocked env values. + $redactEnvironment = ! (bool) auth()->user()?->isAdmin(); + + $array = $diff->toArray(); + $array['changes'] = $this->redactEnvironmentChanges($array['changes'] ?? [], $redactEnvironment); + $this->isConfigurationChanged = $diff->isChanged(); - $this->configurationDiff = $diff->toArray(); - $this->groupedConfigurationChanges = $diff->groupedChanges(); + $this->configurationDiff = $array; return; } $this->isConfigurationChanged = $this->resource->isConfigurationChanged(); $this->configurationDiff = []; - $this->groupedConfigurationChanges = []; } } diff --git a/app/Livewire/Project/Shared/EnvironmentVariable/All.php b/app/Livewire/Project/Shared/EnvironmentVariable/All.php index f5f0f32e6..3a5145023 100644 --- a/app/Livewire/Project/Shared/EnvironmentVariable/All.php +++ b/app/Livewire/Project/Shared/EnvironmentVariable/All.php @@ -7,6 +7,8 @@ use App\Support\ValidationPatterns; use App\Traits\EnvironmentVariableProtection; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; +use Illuminate\Support\Collection; +use Illuminate\Support\Str; use Livewire\Component; class All extends Component @@ -25,6 +27,8 @@ class All extends Component public string $view = 'normal'; + public string $search = ''; + public bool $is_env_sorting_enabled = false; public bool $use_build_secrets = false; @@ -35,6 +39,20 @@ class All extends Component 'environmentVariableDeleted' => 'refreshEnvs', ]; + public function updatedSearch(): void + { + $this->clearEnvironmentVariableCaches(); + } + + private function clearEnvironmentVariableCaches(): void + { + unset($this->environmentVariables); + unset($this->environmentVariablesPreview); + unset($this->hardcodedEnvironmentVariables); + unset($this->hardcodedEnvironmentVariablesPreview); + unset($this->hasEnvironmentVariables); + } + public function mount() { $this->is_env_sorting_enabled = data_get($this->resource, 'settings.is_env_sorting_enabled', false); @@ -65,8 +83,27 @@ public function instantSave() public function getEnvironmentVariablesProperty() { - $query = $this->resource->environment_variables() - ->orderByRaw("CASE WHEN is_required = true AND (value IS NULL OR value = '') THEN 0 ELSE 1 END"); + return $this->getEnvironmentVariables(false); + } + + public function getEnvironmentVariablesPreviewProperty() + { + return $this->getEnvironmentVariables(true); + } + + private function getEnvironmentVariables(bool $isPreview, bool $withSearch = true): Collection + { + $query = $isPreview + ? $this->resource->environment_variables_preview() + : $this->resource->environment_variables(); + + $query->orderByRaw("CASE WHEN is_required = true AND (value IS NULL OR value = '') THEN 0 ELSE 1 END"); + + if ($withSearch && $this->searchTerm() !== '') { + $escapedSearch = addcslashes(Str::lower($this->searchTerm()), '%_\\'); + + $query->whereRaw("LOWER(key) LIKE ? ESCAPE '\\'", ['%'.$escapedSearch.'%']); + } if ($this->is_env_sorting_enabled) { $query->orderBy('key'); @@ -77,18 +114,17 @@ public function getEnvironmentVariablesProperty() return $this->nullLockedValues($query->get()); } - public function getEnvironmentVariablesPreviewProperty() + private function searchTerm(): string { - $query = $this->resource->environment_variables_preview() - ->orderByRaw("CASE WHEN is_required = true AND (value IS NULL OR value = '') THEN 0 ELSE 1 END"); + return trim($this->search); + } - if ($this->is_env_sorting_enabled) { - $query->orderBy('key'); - } else { - $query->orderBy('order'); - } - - return $this->nullLockedValues($query->get()); + public function getHasEnvironmentVariablesProperty(): bool + { + return $this->environmentVariables->isNotEmpty() || + $this->environmentVariablesPreview->isNotEmpty() || + $this->hardcodedEnvironmentVariables->isNotEmpty() || + $this->hardcodedEnvironmentVariablesPreview->isNotEmpty(); } private function nullLockedValues($envs) @@ -105,6 +141,11 @@ private function nullLockedValues($envs) return $envs; } + public function getIsSearchActiveProperty(): bool + { + return $this->searchTerm() !== ''; + } + public function getHardcodedEnvironmentVariablesProperty() { return $this->getHardcodedVariables(false); @@ -152,6 +193,12 @@ protected function getHardcodedVariables(bool $isPreview) return ! in_array($var['key'], $managedKeys); }); + if ($this->searchTerm() !== '') { + $hardcodedVars = $hardcodedVars->filter(function ($var) { + return str($var['key'])->contains($this->searchTerm(), true); + }); + } + // Apply sorting based on is_env_sorting_enabled if ($this->is_env_sorting_enabled) { $hardcodedVars = $hardcodedVars->sortBy('key')->values(); @@ -163,9 +210,9 @@ protected function getHardcodedVariables(bool $isPreview) public function getDevView() { - $this->variables = $this->formatEnvironmentVariables($this->environmentVariables); + $this->variables = $this->formatEnvironmentVariables($this->getEnvironmentVariables(false, false)); if ($this->showPreview) { - $this->variablesPreview = $this->formatEnvironmentVariables($this->environmentVariablesPreview); + $this->variablesPreview = $this->formatEnvironmentVariables($this->getEnvironmentVariables(true, false)); } } @@ -301,9 +348,7 @@ private function handleSingleSubmit($data) $environment->order = $maxOrder + 1; $environment->save(); - // Clear computed property cache to force refresh - unset($this->environmentVariables); - unset($this->environmentVariablesPreview); + $this->clearEnvironmentVariableCaches(); $this->dispatch('success', 'Environment variable added.'); } @@ -432,9 +477,7 @@ private function updateOrCreateVariables($isPreview, $variables) public function refreshEnvs() { $this->resource->refresh(); - // Clear computed property cache to force refresh - unset($this->environmentVariables); - unset($this->environmentVariablesPreview); + $this->clearEnvironmentVariableCaches(); $this->getDevView(); } } diff --git a/app/Livewire/Project/Shared/ResourceDetails.php b/app/Livewire/Project/Shared/ResourceDetails.php new file mode 100644 index 000000000..8a4117c39 --- /dev/null +++ b/app/Livewire/Project/Shared/ResourceDetails.php @@ -0,0 +1,91 @@ +authorize('view', $this->resource); + + $environment = $this->resource->environment ?? null; + if ($environment) { + $this->environment_uuid = $environment->uuid; + $this->environment_name = $environment->name; + $project = $environment->project ?? null; + if ($project) { + $this->project_uuid = $project->uuid; + $this->project_name = $project->name; + } + } + + $server = $this->resolveServer(); + if ($server) { + $this->server_uuid = $server->uuid; + $this->server_name = $server->name; + } + + if ($this->resource instanceof Service) { + $this->stack_applications = $this->resource->applications + ->map(fn ($app) => [ + 'name' => $app->human_name ?: $app->name, + 'uuid' => $app->uuid, + ]) + ->values() + ->all(); + + $this->stack_databases = $this->resource->databases + ->map(fn ($db) => [ + 'name' => $db->human_name ?: $db->name, + 'uuid' => $db->uuid, + ]) + ->values() + ->all(); + } + } + + private function resolveServer() + { + try { + if (isset($this->resource->destination) && $this->resource->destination && isset($this->resource->destination->server)) { + return $this->resource->destination->server; + } + if (method_exists($this->resource, 'server') && $this->resource->server) { + return $this->resource->server; + } + } catch (\Throwable $e) { + return null; + } + + return null; + } + + public function render() + { + return view('livewire.project.shared.resource-details'); + } +} diff --git a/app/Livewire/Server/Charts.php b/app/Livewire/Server/Charts.php index d0db87f57..1cda771a7 100644 --- a/app/Livewire/Server/Charts.php +++ b/app/Livewire/Server/Charts.php @@ -2,11 +2,15 @@ namespace App\Livewire\Server; +use App\Actions\Server\StartSentinel; use App\Models\Server; +use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Livewire\Component; class Charts extends Component { + use AuthorizesRequests; + public Server $server; public $chartId = 'server'; @@ -28,6 +32,29 @@ public function mount(string $server_uuid) } } + public function toggleMetrics(): void + { + try { + $this->authorize('update', $this->server); + $this->server->settings->is_metrics_enabled = ! $this->server->settings->is_metrics_enabled; + $this->server->settings->save(); + $this->server->refresh(); + + if ($this->server->isMetricsEnabled()) { + StartSentinel::run($this->server, true); + $this->dispatch('success', 'Metrics enabled. Starting Sentinel.'); + $this->dispatch('refreshServerShow'); + $this->redirect(route('server.metrics', ['server_uuid' => $this->server->uuid]), navigate: true); + } else { + $this->server->restartSentinel(); + $this->dispatch('success', 'Metrics disabled. Restarting Sentinel.'); + $this->dispatch('refreshServerShow'); + } + } catch (\Throwable $e) { + handleError($e, $this); + } + } + public function pollData() { if ($this->poll || $this->interval <= 10) { diff --git a/app/Livewire/Server/New/ByIp.php b/app/Livewire/Server/New/ByIp.php index 51c6a06ee..f5ea2ae80 100644 --- a/app/Livewire/Server/New/ByIp.php +++ b/app/Livewire/Server/New/ByIp.php @@ -57,7 +57,7 @@ protected function rules(): array 'name' => ValidationPatterns::nameRules(), 'description' => ValidationPatterns::descriptionRules(), 'ip' => ['required', 'string', new ValidServerIp], - 'user' => ['required', 'string', 'regex:/^[a-zA-Z0-9_-]+$/'], + 'user' => ValidationPatterns::serverUsernameRules(), 'port' => 'required|integer|between:1,65535', 'is_build_server' => 'required|boolean', ]; @@ -75,6 +75,7 @@ protected function messages(): array 'ip.string' => 'The IP Address/Domain must be a string.', 'user.required' => 'The User field is required.', 'user.string' => 'The User field must be a string.', + ...ValidationPatterns::serverUsernameMessages(), 'port.required' => 'The Port field is required.', 'port.integer' => 'The Port field must be an integer.', 'port.between' => 'The Port field must be between 1 and 65535.', diff --git a/app/Livewire/Server/Sentinel.php b/app/Livewire/Server/Sentinel.php index 06aebd8f8..909ed54f9 100644 --- a/app/Livewire/Server/Sentinel.php +++ b/app/Livewire/Server/Sentinel.php @@ -15,8 +15,6 @@ class Sentinel extends Component public Server $server; - public array $parameters = []; - public bool $isMetricsEnabled; #[Validate(['required', 'string', 'max:500', 'regex:/\A[a-zA-Z0-9._\-+=\/]+\z/'])] @@ -51,15 +49,9 @@ public function getListeners() ]; } - public function mount(string $server_uuid) + public function mount() { - try { - $this->server = Server::ownedByCurrentTeam()->whereUuid($server_uuid)->firstOrFail(); - $this->parameters = get_route_parameters(); - $this->syncData(); - } catch (\Throwable) { - return redirect()->route('server.index'); - } + $this->syncData(); } public function syncData(bool $toModel = false) @@ -112,27 +104,29 @@ public function restartSentinel() } } - public function updatedIsSentinelEnabled($value) + public function toggleSentinel(): void { try { $this->authorize('manageSentinel', $this->server); - if ($value === true) { + if (! $this->isSentinelEnabled) { if ($this->server->isBuildServer()) { - $this->isSentinelEnabled = false; $this->dispatch('error', 'Sentinel cannot be enabled on build servers.'); return; } + $this->isSentinelEnabled = true; $customImage = isDev() ? $this->sentinelCustomDockerImage : null; StartSentinel::run($this->server, true, null, $customImage); } else { + $this->isSentinelEnabled = false; $this->isMetricsEnabled = false; $this->isSentinelDebugEnabled = false; StopSentinel::dispatch($this->server); } $this->submit(); + $this->dispatch('refreshServerShow'); } catch (\Throwable $e) { - return handleError($e, $this); + handleError($e, $this); } } diff --git a/app/Livewire/Server/Sentinel/Logs.php b/app/Livewire/Server/Sentinel/Logs.php new file mode 100644 index 000000000..6619e101e --- /dev/null +++ b/app/Livewire/Server/Sentinel/Logs.php @@ -0,0 +1,29 @@ +parameters = get_route_parameters(); + try { + $this->server = Server::ownedByCurrentTeam()->whereUuid(request()->server_uuid)->firstOrFail(); + } catch (\Throwable $e) { + handleError($e, $this); + } + } + + public function render(): View + { + return view('livewire.server.sentinel.logs'); + } +} diff --git a/app/Livewire/Server/Sentinel/Show.php b/app/Livewire/Server/Sentinel/Show.php new file mode 100644 index 000000000..7070a09ce --- /dev/null +++ b/app/Livewire/Server/Sentinel/Show.php @@ -0,0 +1,29 @@ +parameters = get_route_parameters(); + try { + $this->server = Server::ownedByCurrentTeam()->whereUuid(request()->server_uuid)->firstOrFail(); + } catch (\Throwable $e) { + handleError($e, $this); + } + } + + public function render(): View + { + return view('livewire.server.sentinel.show'); + } +} diff --git a/app/Livewire/Server/Show.php b/app/Livewire/Server/Show.php index dc6a775a0..d14046ed1 100644 --- a/app/Livewire/Server/Show.php +++ b/app/Livewire/Server/Show.php @@ -110,7 +110,7 @@ protected function rules(): array 'name' => ValidationPatterns::nameRules(), 'description' => ValidationPatterns::descriptionRules(), 'ip' => ['required', new ValidServerIp], - 'user' => ['required', 'regex:/^[a-zA-Z0-9_-]+$/'], + 'user' => ValidationPatterns::serverUsernameRules(), 'port' => 'required|integer|between:1,65535', 'connectionTimeout' => 'required|integer|min:1|max:300', 'validationLogs' => 'nullable', @@ -140,6 +140,7 @@ protected function messages(): array [ 'ip.required' => 'The IP Address field is required.', 'user.required' => 'The User field is required.', + ...ValidationPatterns::serverUsernameMessages(), 'port.required' => 'The Port field is required.', 'connectionTimeout.required' => 'The SSH Connection Timeout field is required.', 'connectionTimeout.integer' => 'The SSH Connection Timeout must be an integer.', diff --git a/app/Livewire/Settings/ScheduledJobs.php b/app/Livewire/Settings/ScheduledJobs.php index 1e54f1483..3655329b1 100644 --- a/app/Livewire/Settings/ScheduledJobs.php +++ b/app/Livewire/Settings/ScheduledJobs.php @@ -8,6 +8,15 @@ use App\Models\ScheduledTask; use App\Models\ScheduledTaskExecution; use App\Models\Server; +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\Services\SchedulerLogParser; use Illuminate\Support\Carbon; use Illuminate\Support\Collection; @@ -125,7 +134,21 @@ private function enrichSkipLogsWithLinks(Collection $skipLogs): Collection : collect(); $backups = $backupIds->isNotEmpty() - ? ScheduledDatabaseBackup::with(['database.environment.project'])->whereIn('id', $backupIds)->get()->keyBy('id') + ? ScheduledDatabaseBackup::with('database') + ->whereIn('id', $backupIds) + ->get() + ->loadMorph('database', [ + ServiceDatabase::class => ['service.environment.project'], + StandaloneClickhouse::class => ['environment.project'], + StandaloneDragonfly::class => ['environment.project'], + StandaloneKeydb::class => ['environment.project'], + StandaloneMariadb::class => ['environment.project'], + StandaloneMongodb::class => ['environment.project'], + StandaloneMysql::class => ['environment.project'], + StandalonePostgresql::class => ['environment.project'], + StandaloneRedis::class => ['environment.project'], + ]) + ->keyBy('id') : collect(); $servers = $serverIds->isNotEmpty() @@ -161,14 +184,29 @@ private function enrichSkipLogsWithLinks(Collection $skipLogs): Collection if ($backup) { $database = $backup->database; $skip['resource_name'] = $database?->name ?? 'Database backup'; - $environment = $database?->environment; - $project = $environment?->project; - if ($project && $environment && $database) { - $skip['link'] = route('project.database.backup.index', [ - 'project_uuid' => $project->uuid, - 'environment_uuid' => $environment->uuid, - 'database_uuid' => $database->uuid, - ]); + + if ($database instanceof ServiceDatabase) { + $service = $database->service; + $environment = $service?->environment; + $project = $environment?->project; + if ($project && $environment && $service) { + $skip['link'] = route('project.service.database.backups', [ + 'project_uuid' => $project->uuid, + 'environment_uuid' => $environment->uuid, + 'service_uuid' => $service->uuid, + 'stack_service_uuid' => $database->uuid, + ]); + } + } else { + $environment = $database?->environment; + $project = $environment?->project; + if ($project && $environment && $database) { + $skip['link'] = route('project.database.backup.index', [ + 'project_uuid' => $project->uuid, + 'environment_uuid' => $environment->uuid, + 'database_uuid' => $database->uuid, + ]); + } } } } elseif ($skip['type'] === 'docker_cleanup') { diff --git a/app/Livewire/Source/Github/Change.php b/app/Livewire/Source/Github/Change.php index 1470b95db..648bfe6ee 100644 --- a/app/Livewire/Source/Github/Change.php +++ b/app/Livewire/Source/Github/Change.php @@ -210,6 +210,9 @@ public function checkPermissions() GithubAppPermissionJob::dispatchSync($this->github_app); $this->github_app->refresh()->makeVisible('client_secret')->makeVisible('webhook_secret'); + $this->syncData(false); + $this->name = str($this->github_app->name)->kebab(); + $this->dispatch('success', 'Github App permissions updated.'); } catch (\Throwable $e) { // Provide better error message for unsupported key formats diff --git a/app/Livewire/Team/InviteLink.php b/app/Livewire/Team/InviteLink.php index ee6d535e9..fb30961e9 100644 --- a/app/Livewire/Team/InviteLink.php +++ b/app/Livewire/Team/InviteLink.php @@ -61,7 +61,7 @@ private function generateInviteLink(bool $sendEmail = false) if ($member_emails->contains($this->email)) { return handleError(livewire: $this, customErrorMessage: "$this->email is already a member of ".currentTeam()->name.'.'); } - $uuid = new Cuid2(32); + $uuid = (string) new Cuid2(32); $link = url('/').config('constants.invitation.link.base_url').$uuid; $user = User::whereEmail($this->email)->first(); @@ -73,7 +73,7 @@ private function generateInviteLink(bool $sendEmail = false) 'password' => Hash::make($password), 'force_password_reset' => true, ]); - $token = Crypt::encryptString("{$user->email}@@@$password"); + $token = Crypt::encryptString("{$user->email}@@@{$uuid}@@@{$password}"); $link = route('auth.link', ['token' => $token]); } $invitation = TeamInvitation::whereEmail($this->email)->first(); diff --git a/app/Models/Application.php b/app/Models/Application.php index 3905281d8..b2f852f15 100644 --- a/app/Models/Application.php +++ b/app/Models/Application.php @@ -204,6 +204,7 @@ class Application extends BaseModel 'config_hash', 'last_online_at', 'restart_count', + 'max_restart_count', 'last_restart_at', 'last_restart_type', 'uuid', @@ -227,6 +228,7 @@ protected function casts(): array 'manual_webhook_secret_bitbucket' => 'encrypted', 'manual_webhook_secret_gitea' => 'encrypted', 'restart_count' => 'integer', + 'max_restart_count' => 'integer', 'last_restart_at' => 'datetime', ]; } @@ -570,6 +572,15 @@ public function link() return null; } + public function stoppedAfterRestartLimit(): bool + { + return str($this->status)->startsWith('exited') + && ($this->restart_count ?? 0) > 0 + && ($this->max_restart_count ?? 0) > 0 + && $this->restart_count >= $this->max_restart_count + && $this->last_restart_type === 'crash'; + } + public function taskLink($task_uuid) { if (data_get($this, 'environment.project.uuid')) { @@ -1347,6 +1358,7 @@ public function generateGitLsRemoteCommands(string $deployment_uuid, bool $exec_ $branch = $this->git_branch; ['repository' => $customRepository, 'port' => $customPort] = $this->customRepository(); $commands = collect([]); + $customSshKeyLocation = "/root/.ssh/id_rsa_coolify_{$deployment_uuid}"; $base_command = 'git ls-remote'; if ($this->deploymentType() === 'source') { @@ -1400,19 +1412,20 @@ public function generateGitLsRemoteCommands(string $deployment_uuid, bool $exec_ $private_key = base64_encode($private_key); $gitlabPort = $gitlabSource->custom_port ?? 22; $escapedCustomRepository = str_replace("'", "'\\''", $customRepository); - $base_command = "GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$gitlabPort} -o Port={$gitlabPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa\" {$base_command} '{$escapedCustomRepository}'"; + $base_command = "GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$gitlabPort} -o Port={$gitlabPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i {$customSshKeyLocation} -o IdentitiesOnly=yes\" {$base_command} '{$escapedCustomRepository}'"; if ($exec_in_docker) { $commands = collect([ executeInDocker($deployment_uuid, 'mkdir -p /root/.ssh'), - executeInDocker($deployment_uuid, "echo '{$private_key}' | base64 -d | tee /root/.ssh/id_rsa > /dev/null"), - executeInDocker($deployment_uuid, 'chmod 600 /root/.ssh/id_rsa'), + executeInDocker($deployment_uuid, "echo '{$private_key}' | base64 -d | tee {$customSshKeyLocation} > /dev/null"), + executeInDocker($deployment_uuid, "chmod 600 {$customSshKeyLocation}"), ]); } else { $commands = collect([ + "trap 'rm -f {$customSshKeyLocation}' EXIT", 'mkdir -p /root/.ssh', - "echo '{$private_key}' | base64 -d | tee /root/.ssh/id_rsa > /dev/null", - 'chmod 600 /root/.ssh/id_rsa', + "echo '{$private_key}' | base64 -d | tee {$customSshKeyLocation} > /dev/null", + "chmod 600 {$customSshKeyLocation}", ]); } @@ -1458,19 +1471,20 @@ public function generateGitLsRemoteCommands(string $deployment_uuid, bool $exec_ // When used with executeInDocker (which uses bash -c '...'), we need to escape for bash context // Replace ' with '\'' to safely escape within single-quoted bash strings $escapedCustomRepository = str_replace("'", "'\\''", $customRepository); - $base_command = "GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$customPort} -o Port={$customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa\" {$base_command} '{$escapedCustomRepository}'"; + $base_command = "GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$customPort} -o Port={$customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i {$customSshKeyLocation} -o IdentitiesOnly=yes\" {$base_command} '{$escapedCustomRepository}'"; if ($exec_in_docker) { $commands = collect([ executeInDocker($deployment_uuid, 'mkdir -p /root/.ssh'), - executeInDocker($deployment_uuid, "echo '{$private_key}' | base64 -d | tee /root/.ssh/id_rsa > /dev/null"), - executeInDocker($deployment_uuid, 'chmod 600 /root/.ssh/id_rsa'), + executeInDocker($deployment_uuid, "echo '{$private_key}' | base64 -d | tee {$customSshKeyLocation} > /dev/null"), + executeInDocker($deployment_uuid, "chmod 600 {$customSshKeyLocation}"), ]); } else { $commands = collect([ + "trap 'rm -f {$customSshKeyLocation}' EXIT", 'mkdir -p /root/.ssh', - "echo '{$private_key}' | base64 -d | tee /root/.ssh/id_rsa > /dev/null", - 'chmod 600 /root/.ssh/id_rsa', + "echo '{$private_key}' | base64 -d | tee {$customSshKeyLocation} > /dev/null", + "chmod 600 {$customSshKeyLocation}", ]); } @@ -1506,11 +1520,34 @@ public function generateGitLsRemoteCommands(string $deployment_uuid, bool $exec_ } } + private function withGitHttpTransportConfig(?string $gitConfigOptions = null): string + { + return trim(($gitConfigOptions ? "{$gitConfigOptions} " : '').'-c http.version=HTTP/1.1'); + } + + private function isHttpGitRepository(string $repository): bool + { + return str_starts_with($repository, 'https://') || str_starts_with($repository, 'http://'); + } + + private function applyGitConfigOptionsToCloneCommand(string $gitCloneCommand, string $gitConfigOptions): string + { + $configuredCommand = preg_replace( + "/^git(?:\s+-c\s+(?:'[^']*'|\S+))*\s+clone\b/", + "git {$gitConfigOptions} clone", + $gitCloneCommand, + 1 + ); + + return $configuredCommand ?: $gitCloneCommand; + } + public function generateGitImportCommands(string $deployment_uuid, int $pull_request_id = 0, ?string $git_type = null, bool $exec_in_docker = true, bool $only_checkout = false, ?string $custom_base_dir = null, ?string $commit = null) { $branch = $this->git_branch; ['repository' => $customRepository, 'port' => $customPort] = $this->customRepository(); $baseDir = $custom_base_dir ?? $this->generateBaseDir($deployment_uuid); + $customSshKeyLocation = "/root/.ssh/id_rsa_coolify_{$deployment_uuid}"; // Escape shell arguments for safety to prevent command injection $escapedBranch = escapeshellarg($branch); @@ -1548,8 +1585,10 @@ public function generateGitImportCommands(string $deployment_uuid, int $pull_req $fullRepoUrl = "{$this->source->html_url}/{$customRepository}"; $escapedRepoUrl = escapeshellarg("{$this->source->html_url}/{$customRepository}"); $git_clone_command = "{$git_clone_command} {$escapedRepoUrl} {$escapedBaseDir}"; + $gitConfigOptions = $this->withGitHttpTransportConfig(); + $git_clone_command = $this->applyGitConfigOptionsToCloneCommand($git_clone_command, $gitConfigOptions); if (! $only_checkout) { - $git_clone_command = $this->setGitImportSettings($deployment_uuid, $git_clone_command, public: true, commit: $commit); + $git_clone_command = $this->setGitImportSettings($deployment_uuid, $git_clone_command, public: true, commit: $commit, gitConfigOptions: $gitConfigOptions); } if ($exec_in_docker) { $commands->push(executeInDocker($deployment_uuid, $git_clone_command)); @@ -1562,6 +1601,7 @@ public function generateGitImportCommands(string $deployment_uuid, int $pull_req // Rewrite same-host HTTPS URLs only for these git commands so submodules can authenticate without persisting credentials. $gitConfigOption = '-c '.escapeshellarg("url.{$source_html_url_scheme}://x-access-token:{$encodedToken}@{$source_html_url_host}/.insteadOf={$source_html_url_scheme}://{$source_html_url_host}/"); + $gitConfigOptions = $this->withGitHttpTransportConfig($gitConfigOption); $git_clone_command = str_replace('git clone', "git {$gitConfigOption} clone", $git_clone_command); if ($exec_in_docker) { @@ -1575,8 +1615,9 @@ public function generateGitImportCommands(string $deployment_uuid, int $pull_req $git_clone_command = "{$git_clone_command} {$escapedRepoUrl} {$escapedBaseDir}"; $fullRepoUrl = $repoUrl; } + $git_clone_command = $this->applyGitConfigOptionsToCloneCommand($git_clone_command, $gitConfigOptions); if (! $only_checkout) { - $git_clone_command = $this->setGitImportSettings($deployment_uuid, $git_clone_command, public: false, commit: $commit, gitConfigOptions: $gitConfigOption); + $git_clone_command = $this->setGitImportSettings($deployment_uuid, $git_clone_command, public: false, commit: $commit, gitConfigOptions: $gitConfigOptions); } if ($exec_in_docker) { $commands->push(executeInDocker($deployment_uuid, $git_clone_command)); @@ -1587,12 +1628,13 @@ public function generateGitImportCommands(string $deployment_uuid, int $pull_req if ($pull_request_id !== 0) { $branch = "pull/{$pull_request_id}/head:$pr_branch_name"; - $git_checkout_command = $this->buildGitCheckoutCommand($pr_branch_name, gitConfigOptions: $gitConfigOption ?? null); + $git_checkout_command = $this->buildGitCheckoutCommand($pr_branch_name, gitConfigOptions: $gitConfigOptions ?? null); + $gitCommand = isset($gitConfigOptions) ? "git {$gitConfigOptions}" : 'git'; $escapedPrBranch = escapeshellarg($branch); if ($exec_in_docker) { - $commands->push(executeInDocker($deployment_uuid, "cd {$escapedBaseDir} && git fetch origin {$escapedPrBranch} && $git_checkout_command")); + $commands->push(executeInDocker($deployment_uuid, "cd {$escapedBaseDir} && {$gitCommand} fetch origin {$escapedPrBranch} && $git_checkout_command")); } else { - $commands->push("cd {$escapedBaseDir} && git fetch origin {$escapedPrBranch} && $git_checkout_command"); + $commands->push("cd {$escapedBaseDir} && {$gitCommand} fetch origin {$escapedPrBranch} && $git_checkout_command"); } } @@ -1612,7 +1654,7 @@ public function generateGitImportCommands(string $deployment_uuid, int $pull_req $private_key = base64_encode($private_key); $gitlabPort = $gitlabSource->custom_port ?? 22; $escapedCustomRepository = escapeshellarg($customRepository); - $gitlabSshCommand = "ssh -o ConnectTimeout=30 -p {$gitlabPort} -o Port={$gitlabPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa"; + $gitlabSshCommand = "ssh -o ConnectTimeout=30 -p {$gitlabPort} -o Port={$gitlabPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i {$customSshKeyLocation} -o IdentitiesOnly=yes"; $gitlabGitSshCommand = "GIT_SSH_COMMAND=\"{$gitlabSshCommand}\""; $git_clone_command_base = "{$gitlabGitSshCommand} {$git_clone_command} {$escapedCustomRepository} {$escapedBaseDir}"; if ($only_checkout) { @@ -1623,14 +1665,15 @@ public function generateGitImportCommands(string $deployment_uuid, int $pull_req if ($exec_in_docker) { $commands = collect([ executeInDocker($deployment_uuid, 'mkdir -p /root/.ssh'), - executeInDocker($deployment_uuid, "echo '{$private_key}' | base64 -d | tee /root/.ssh/id_rsa > /dev/null"), - executeInDocker($deployment_uuid, 'chmod 600 /root/.ssh/id_rsa'), + executeInDocker($deployment_uuid, "echo '{$private_key}' | base64 -d | tee {$customSshKeyLocation} > /dev/null"), + executeInDocker($deployment_uuid, "chmod 600 {$customSshKeyLocation}"), ]); } else { $commands = collect([ + "trap 'rm -f {$customSshKeyLocation}' EXIT", 'mkdir -p /root/.ssh', - "echo '{$private_key}' | base64 -d | tee /root/.ssh/id_rsa > /dev/null", - 'chmod 600 /root/.ssh/id_rsa', + "echo '{$private_key}' | base64 -d | tee {$customSshKeyLocation} > /dev/null", + "chmod 600 {$customSshKeyLocation}", ]); } @@ -1661,7 +1704,11 @@ public function generateGitImportCommands(string $deployment_uuid, int $pull_req $fullRepoUrl = $customRepository; $escapedCustomRepository = escapeshellarg($customRepository); $git_clone_command = "{$git_clone_command} {$escapedCustomRepository} {$escapedBaseDir}"; - $git_clone_command = $this->setGitImportSettings($deployment_uuid, $git_clone_command, public: true, commit: $commit); + $gitConfigOptions = $this->isHttpGitRepository($customRepository) ? $this->withGitHttpTransportConfig() : null; + if ($gitConfigOptions) { + $git_clone_command = $this->applyGitConfigOptionsToCloneCommand($git_clone_command, $gitConfigOptions); + } + $git_clone_command = $this->setGitImportSettings($deployment_uuid, $git_clone_command, public: true, commit: $commit, gitConfigOptions: $gitConfigOptions); if ($exec_in_docker) { $commands->push(executeInDocker($deployment_uuid, $git_clone_command)); @@ -1684,7 +1731,7 @@ public function generateGitImportCommands(string $deployment_uuid, int $pull_req } $private_key = base64_encode($private_key); $escapedCustomRepository = escapeshellarg($customRepository); - $deployKeySshCommand = "ssh -o ConnectTimeout=30 -p {$customPort} -o Port={$customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa"; + $deployKeySshCommand = "ssh -o ConnectTimeout=30 -p {$customPort} -o Port={$customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i {$customSshKeyLocation} -o IdentitiesOnly=yes"; $deployKeyGitSshCommand = "GIT_SSH_COMMAND=\"{$deployKeySshCommand}\""; $git_clone_command_base = "{$deployKeyGitSshCommand} {$git_clone_command} {$escapedCustomRepository} {$escapedBaseDir}"; if ($only_checkout) { @@ -1695,14 +1742,15 @@ public function generateGitImportCommands(string $deployment_uuid, int $pull_req if ($exec_in_docker) { $commands = collect([ executeInDocker($deployment_uuid, 'mkdir -p /root/.ssh'), - executeInDocker($deployment_uuid, "echo '{$private_key}' | base64 -d | tee /root/.ssh/id_rsa > /dev/null"), - executeInDocker($deployment_uuid, 'chmod 600 /root/.ssh/id_rsa'), + executeInDocker($deployment_uuid, "echo '{$private_key}' | base64 -d | tee {$customSshKeyLocation} > /dev/null"), + executeInDocker($deployment_uuid, "chmod 600 {$customSshKeyLocation}"), ]); } else { $commands = collect([ + "trap 'rm -f {$customSshKeyLocation}' EXIT", 'mkdir -p /root/.ssh', - "echo '{$private_key}' | base64 -d | tee /root/.ssh/id_rsa > /dev/null", - 'chmod 600 /root/.ssh/id_rsa', + "echo '{$private_key}' | base64 -d | tee {$customSshKeyLocation} > /dev/null", + "chmod 600 {$customSshKeyLocation}", ]); } if ($pull_request_id !== 0) { @@ -1713,7 +1761,7 @@ public function generateGitImportCommands(string $deployment_uuid, int $pull_req } else { $commands->push("echo 'Checking out $branch'"); } - $git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && GIT_SSH_COMMAND=\"{$deployKeySshCommand}\" git fetch origin $branch && ".$this->buildGitCheckoutCommand($pr_branch_name, $deployKeySshCommand); + $git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && {$deployKeyGitSshCommand} git fetch origin $branch && ".$this->buildGitCheckoutCommand($pr_branch_name, $deployKeySshCommand); } elseif ($git_type === 'github' || $git_type === 'gitea') { $branch = "pull/{$pull_request_id}/head:$pr_branch_name"; if ($exec_in_docker) { @@ -1721,14 +1769,14 @@ public function generateGitImportCommands(string $deployment_uuid, int $pull_req } else { $commands->push("echo 'Checking out $branch'"); } - $git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && GIT_SSH_COMMAND=\"{$deployKeySshCommand}\" git fetch origin $branch && ".$this->buildGitCheckoutCommand($pr_branch_name, $deployKeySshCommand); + $git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && {$deployKeyGitSshCommand} git fetch origin $branch && ".$this->buildGitCheckoutCommand($pr_branch_name, $deployKeySshCommand); } elseif ($git_type === 'bitbucket') { if ($exec_in_docker) { $commands->push(executeInDocker($deployment_uuid, "echo 'Checking out $branch'")); } else { $commands->push("echo 'Checking out $branch'"); } - $git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && GIT_SSH_COMMAND=\"{$deployKeySshCommand}\" ".$this->buildGitCheckoutCommand($commit, $deployKeySshCommand); + $git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && {$deployKeyGitSshCommand} ".$this->buildGitCheckoutCommand($commit, $deployKeySshCommand); } } @@ -1748,10 +1796,15 @@ public function generateGitImportCommands(string $deployment_uuid, int $pull_req $fullRepoUrl = $customRepository; $escapedCustomRepository = escapeshellarg($customRepository); $git_clone_command = "{$git_clone_command} {$escapedCustomRepository} {$escapedBaseDir}"; - $git_clone_command = $this->setGitImportSettings($deployment_uuid, $git_clone_command, public: true, commit: $commit); + $gitConfigOptions = $this->isHttpGitRepository($customRepository) ? $this->withGitHttpTransportConfig() : null; + if ($gitConfigOptions) { + $git_clone_command = $this->applyGitConfigOptionsToCloneCommand($git_clone_command, $gitConfigOptions); + } + $git_clone_command = $this->setGitImportSettings($deployment_uuid, $git_clone_command, public: true, commit: $commit, gitConfigOptions: $gitConfigOptions); $otherSshCommand = "ssh -o ConnectTimeout=30 -p {$customPort} -o Port={$customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa"; if ($pull_request_id !== 0) { + $gitCommand = isset($gitConfigOptions) ? "git {$gitConfigOptions}" : 'git'; if ($git_type === 'gitlab') { $branch = "merge-requests/{$pull_request_id}/head:$pr_branch_name"; if ($exec_in_docker) { @@ -1759,7 +1812,7 @@ public function generateGitImportCommands(string $deployment_uuid, int $pull_req } else { $commands->push("echo 'Checking out $branch'"); } - $git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && GIT_SSH_COMMAND=\"{$otherSshCommand}\" git fetch origin $branch && ".$this->buildGitCheckoutCommand($pr_branch_name, $otherSshCommand); + $git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && GIT_SSH_COMMAND=\"{$otherSshCommand}\" {$gitCommand} fetch origin $branch && ".$this->buildGitCheckoutCommand($pr_branch_name, $otherSshCommand, $gitConfigOptions); } elseif ($git_type === 'github' || $git_type === 'gitea') { $branch = "pull/{$pull_request_id}/head:$pr_branch_name"; if ($exec_in_docker) { @@ -1767,14 +1820,14 @@ public function generateGitImportCommands(string $deployment_uuid, int $pull_req } else { $commands->push("echo 'Checking out $branch'"); } - $git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && GIT_SSH_COMMAND=\"{$otherSshCommand}\" git fetch origin $branch && ".$this->buildGitCheckoutCommand($pr_branch_name, $otherSshCommand); + $git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && GIT_SSH_COMMAND=\"{$otherSshCommand}\" {$gitCommand} fetch origin $branch && ".$this->buildGitCheckoutCommand($pr_branch_name, $otherSshCommand, $gitConfigOptions); } elseif ($git_type === 'bitbucket') { if ($exec_in_docker) { $commands->push(executeInDocker($deployment_uuid, "echo 'Checking out $branch'")); } else { $commands->push("echo 'Checking out $branch'"); } - $git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && GIT_SSH_COMMAND=\"{$otherSshCommand}\" ".$this->buildGitCheckoutCommand($commit, $otherSshCommand); + $git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && GIT_SSH_COMMAND=\"{$otherSshCommand}\" ".$this->buildGitCheckoutCommand($commit, $otherSshCommand, $gitConfigOptions); } } @@ -1873,7 +1926,8 @@ public function loadComposeFile($isInit = false, ?string $restoreBaseDirectory = return; } $uuid = new Cuid2; - ['commands' => $cloneCommand] = $this->generateGitImportCommands(deployment_uuid: $uuid, only_checkout: true, exec_in_docker: false, custom_base_dir: '.'); + ['commands' => $cloneCommand] = $this->generateGitImportCommands(deployment_uuid: $uuid, only_checkout: true, exec_in_docker: false, custom_base_dir: 'checkout'); + $cloneCommand = str_replace(' clone ', ' clone --quiet ', $cloneCommand); $workdir = rtrim($this->base_directory, '/'); $composeFile = $this->docker_compose_location; $fileList = collect([".$workdir$composeFile"]); @@ -1903,6 +1957,7 @@ public function loadComposeFile($isInit = false, ?string $restoreBaseDirectory = "mkdir -p /tmp/{$uuid}", "cd /tmp/{$uuid}", $cloneCommand, + 'cd checkout', 'git sparse-checkout init', "git sparse-checkout set {$fileList->implode(' ')}", 'git read-tree -mu HEAD', @@ -1914,6 +1969,7 @@ public function loadComposeFile($isInit = false, ?string $restoreBaseDirectory = "mkdir -p /tmp/{$uuid}", "cd /tmp/{$uuid}", $cloneCommand, + 'cd checkout', 'git sparse-checkout init --cone', "git sparse-checkout set {$fileList->implode(' ')}", 'git read-tree -mu HEAD', @@ -2346,7 +2402,7 @@ public function setConfig($config) 'config.build_pack' => 'required|string', 'config.base_directory' => 'required|string', 'config.publish_directory' => 'required|string', - 'config.ports_exposes' => 'required|string', + 'config.ports_exposes' => 'nullable|string', 'config.settings.is_static' => 'required|boolean', ]); if ($deepValidator->fails()) { diff --git a/app/Models/ApplicationDeploymentQueue.php b/app/Models/ApplicationDeploymentQueue.php index afac89fa8..53fb8337f 100644 --- a/app/Models/ApplicationDeploymentQueue.php +++ b/app/Models/ApplicationDeploymentQueue.php @@ -2,6 +2,7 @@ namespace App\Models; +use App\Casts\EncryptedArrayCast; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Model; use Illuminate\Support\Carbon; @@ -74,11 +75,24 @@ class ApplicationDeploymentQueue extends Model 'finished_at', ]; + /** + * The configuration snapshot/diff hold full (decrypted on read) configuration, + * including unlocked environment variable values. They are only meant for the + * in-app diff modal (which redacts per role) and must never be serialized by the + * API, so hide them globally as defense in depth. + * + * @var array + */ + protected $hidden = [ + 'configuration_snapshot', + 'configuration_diff', + ]; + protected $casts = [ 'pull_request_id' => 'integer', 'finished_at' => 'datetime', - 'configuration_snapshot' => 'array', - 'configuration_diff' => 'array', + 'configuration_snapshot' => EncryptedArrayCast::class, + 'configuration_diff' => EncryptedArrayCast::class, ]; public function application() diff --git a/app/Models/Server.php b/app/Models/Server.php index 74e8ba5b0..2b7bbac55 100644 --- a/app/Models/Server.php +++ b/app/Models/Server.php @@ -17,6 +17,7 @@ use App\Notifications\Server\Reachable; use App\Notifications\Server\Unreachable; use App\Services\ConfigurationRepository; +use App\Support\ValidationPatterns; use App\Traits\ClearsGlobalSearchCache; use App\Traits\HasMetrics; use App\Traits\HasSafeStringAttribute; @@ -945,10 +946,10 @@ public function user(): Attribute { return Attribute::make( get: function ($value) { - return preg_replace('/[^A-Za-z0-9\-_]/', '', $value); + return preg_replace(ValidationPatterns::INVALID_SERVER_USERNAME_CHARACTERS_PATTERN, '', $value); }, set: function ($value) { - return preg_replace('/[^A-Za-z0-9\-_]/', '', $value); + return preg_replace(ValidationPatterns::INVALID_SERVER_USERNAME_CHARACTERS_PATTERN, '', $value); } ); } diff --git a/app/Models/StandaloneDocker.php b/app/Models/StandaloneDocker.php index d12a15a7c..1c5cfd342 100644 --- a/app/Models/StandaloneDocker.php +++ b/app/Models/StandaloneDocker.php @@ -5,6 +5,7 @@ use App\Jobs\ConnectProxyToNetworksJob; use App\Support\ValidationPatterns; use App\Traits\HasSafeStringAttribute; +use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Factories\HasFactory; class StandaloneDocker extends BaseModel @@ -127,7 +128,7 @@ public function services() return $this->morphMany(Service::class, 'destination'); } - public function databases() + public function databases(): Collection { $postgresqls = $this->postgresqls; $redis = $this->redis; diff --git a/app/Notifications/Application/RestartLimitReached.php b/app/Notifications/Application/RestartLimitReached.php new file mode 100644 index 000000000..635dfdbdc --- /dev/null +++ b/app/Notifications/Application/RestartLimitReached.php @@ -0,0 +1,141 @@ +onQueue('high'); + $this->afterCommit(); + $this->resource_name = data_get($resource, 'name'); + $this->project_uuid = data_get($resource, 'environment.project.uuid'); + $this->environment_uuid = data_get($resource, 'environment.uuid'); + $this->environment_name = data_get($resource, 'environment.name'); + $this->fqdn = data_get($resource, 'fqdn', null); + $this->restart_count = $resource->restart_count; + $this->max_restart_count = $resource->max_restart_count; + if (str($this->fqdn)->explode(',')->count() > 1) { + $this->fqdn = str($this->fqdn)->explode(',')->first(); + } + $this->resource_url = $this->resource->link() ?? base_url()."/project/{$this->project_uuid}/environment/{$this->environment_uuid}/application/{$this->resource->uuid}"; + } + + public function via(object $notifiable): array + { + return $notifiable->getEnabledChannels('status_change'); + } + + public function toMail(): MailMessage + { + $mail = new MailMessage; + $mail->subject("Coolify: {$this->resource_name} stopped - restart limit reached ({$this->restart_count}/{$this->max_restart_count})"); + $mail->view('emails.application-restart-limit-reached', [ + 'name' => $this->resource_name, + 'fqdn' => $this->fqdn, + 'resource_url' => $this->resource_url, + 'restart_count' => $this->restart_count, + 'max_restart_count' => $this->max_restart_count, + ]); + + return $mail; + } + + public function toDiscord(): DiscordMessage + { + return new DiscordMessage( + title: ':warning: Restart limit reached', + description: "{$this->resource_name} has been stopped after {$this->restart_count} restarts (limit: {$this->max_restart_count}).\n\n[Open Application in Coolify]({$this->resource_url})", + color: DiscordMessage::errorColor(), + isCritical: true, + ); + } + + public function toTelegram(): array + { + $message = "Coolify: {$this->resource_name} has been stopped after {$this->restart_count} restarts (limit: {$this->max_restart_count})."; + + return [ + 'message' => $message, + 'buttons' => [ + [ + 'text' => 'Open Application in Coolify', + 'url' => $this->resource_url, + ], + ], + ]; + } + + public function toPushover(): PushoverMessage + { + $message = "{$this->resource_name} has been stopped after {$this->restart_count} restarts (limit: {$this->max_restart_count})."; + + return new PushoverMessage( + title: 'Restart limit reached', + level: 'error', + message: $message, + buttons: [ + [ + 'text' => 'Open Application in Coolify', + 'url' => $this->resource_url, + ], + ], + ); + } + + public function toSlack(): SlackMessage + { + $title = 'Restart limit reached'; + $description = "{$this->resource_name} has been stopped after {$this->restart_count} restarts (limit: {$this->max_restart_count})"; + + $description .= "\n\n*Project:* ".data_get($this->resource, 'environment.project.name'); + $description .= "\n*Environment:* {$this->environment_name}"; + $description .= "\n*Application URL:* {$this->resource_url}"; + + return new SlackMessage( + title: $title, + description: $description, + color: SlackMessage::errorColor() + ); + } + + public function toWebhook(): array + { + return [ + 'success' => false, + 'message' => 'Restart limit reached', + 'event' => 'restart_limit_reached', + 'application_name' => $this->resource_name, + 'application_uuid' => $this->resource->uuid, + 'restart_count' => $this->restart_count, + 'max_restart_count' => $this->max_restart_count, + 'url' => $this->resource_url, + 'project' => data_get($this->resource, 'environment.project.name'), + 'environment' => $this->environment_name, + 'fqdn' => $this->fqdn, + ]; + } +} diff --git a/app/Services/DeploymentConfiguration/ApplicationConfigurationSnapshot.php b/app/Services/DeploymentConfiguration/ApplicationConfigurationSnapshot.php index 8369f9a90..365708758 100644 --- a/app/Services/DeploymentConfiguration/ApplicationConfigurationSnapshot.php +++ b/app/Services/DeploymentConfiguration/ApplicationConfigurationSnapshot.php @@ -4,10 +4,13 @@ use App\Models\Application; use App\Models\EnvironmentVariable; +use App\Services\DeploymentConfiguration\Concerns\SummarizesDiffText; use Illuminate\Support\Arr; class ApplicationConfigurationSnapshot { + use SummarizesDiffText; + public const SCHEMA_VERSION = 1; public function __construct(protected Application $application) {} @@ -115,12 +118,14 @@ private function buildItems(): array $this->item('publish_directory', 'Publish directory', $this->application->publish_directory, 'build'), $this->item('install_command', 'Install command', $this->application->install_command, 'build'), $this->item('build_command', 'Build command', $this->application->build_command, 'build'), - $this->item('dockerfile', 'Dockerfile', $this->application->dockerfile, 'build', displayValue: $this->summarizeText($this->application->dockerfile)), + $this->item('dockerfile', 'Dockerfile', $this->application->dockerfile, 'build', displayValue: $this->summarizeText($this->application->dockerfile), displayFull: $this->application->dockerfile), $this->item('dockerfile_location', 'Dockerfile location', $this->application->dockerfile_location, 'build'), $this->item('dockerfile_target_build', 'Dockerfile target', $this->application->dockerfile_target_build, 'build'), $this->item('docker_compose_location', 'Docker Compose location', $this->application->docker_compose_location, 'build'), - $this->item('docker_compose', 'Docker Compose', $this->application->docker_compose, 'build', displayValue: $this->summarizeText($this->application->docker_compose)), - $this->item('docker_compose_raw', 'Raw Docker Compose', $this->application->docker_compose_raw, 'build', displayValue: $this->summarizeText($this->application->docker_compose_raw)), + // The generated docker_compose is intentionally excluded: it is re-rendered + // from git on every parse (resolved env, generated labels, deployment context), + // so comparing it would flag a permanent change for git-based compose apps. + $this->item('docker_compose_raw', 'Docker Compose', $this->application->docker_compose_raw, 'build', displayValue: $this->summarizeText($this->application->docker_compose_raw), displayFull: $this->application->docker_compose_raw, diffMode: 'lines'), $this->item('docker_compose_custom_build_command', 'Docker Compose custom build command', $this->application->docker_compose_custom_build_command, 'build'), $this->item('custom_docker_run_options', 'Custom Docker run options', $this->application->custom_docker_run_options, 'build'), $this->item('use_build_secrets', 'Use build secrets', data_get($this->application, 'settings.use_build_secrets'), 'build'), @@ -162,9 +167,10 @@ private function domainItems(): array { return [ $this->item('fqdn', 'Domains', $this->application->fqdn, 'redeploy'), + $this->item('docker_compose_domains', 'Service domains', $this->decodedComposeDomains(), 'redeploy', displayValue: $this->summarizeText($this->composeDomainsText()), displayFull: $this->composeDomainsText(), diffMode: 'lines'), $this->item('redirect', 'Redirect', $this->application->redirect, 'redeploy'), - $this->item('custom_labels', 'Container labels', $this->application->custom_labels, 'redeploy', displayValue: $this->summarizeText($this->application->custom_labels)), - $this->item('custom_nginx_configuration', 'Custom Nginx configuration', $this->application->custom_nginx_configuration, 'redeploy', displayValue: $this->summarizeText($this->application->custom_nginx_configuration)), + $this->item('custom_labels', 'Container labels', $this->application->custom_labels, 'redeploy', displayValue: $this->summarizeText($this->decodeCustomLabels($this->application->custom_labels)), displayFull: $this->decodeCustomLabels($this->application->custom_labels), diffMode: 'lines'), + $this->item('custom_nginx_configuration', 'Custom Nginx configuration', $this->application->custom_nginx_configuration, 'redeploy', displayValue: $this->summarizeText($this->application->custom_nginx_configuration), displayFull: $this->application->custom_nginx_configuration), $this->item('is_force_https_enabled', 'Force HTTPS', data_get($this->application, 'settings.is_force_https_enabled'), 'redeploy'), $this->item('is_gzip_enabled', 'Gzip', data_get($this->application, 'settings.is_gzip_enabled'), 'redeploy'), $this->item('is_stripprefix_enabled', 'Strip prefix', data_get($this->application, 'settings.is_stripprefix_enabled'), 'redeploy'), @@ -234,6 +240,7 @@ private function limitItems(): array private function environmentItem(EnvironmentVariable $environmentVariable): array { $impact = $environmentVariable->is_buildtime ? 'build' : 'redeploy'; + $locked = (bool) $environmentVariable->is_shown_once; $compareValue = [ 'value_hash' => $this->sensitiveHash($environmentVariable->value), 'is_multiline' => $environmentVariable->is_multiline, @@ -242,20 +249,62 @@ private function environmentItem(EnvironmentVariable $environmentVariable): arra 'is_runtime' => $environmentVariable->is_runtime, ]; + // Locked (is_shown_once) variables are always redacted and never store a value. + if ($locked) { + return $this->item( + key: (string) $environmentVariable->key, + label: (string) $environmentVariable->key, + value: $compareValue, + impact: $impact, + sensitive: true, + displayValue: $this->environmentDisplayValue($environmentVariable), + ); + } + + // Unlocked variables expose their value so owners/admins can see the change. + // The compare value is pre-hashed (identical formula to the locked branch) so + // change detection stays stable and never carries the raw value; members are + // redacted at render time in ConfigurationChecker; the column is encrypted at rest. + // The value and each scope flag are rendered as their own line and diffed by line, + // so a change to one or more attributes shows exactly what changed (one line each). + $value = (string) $environmentVariable->value; + return $this->item( key: (string) $environmentVariable->key, label: (string) $environmentVariable->key, - value: $compareValue, + value: $this->sensitiveHash($this->normalizeValue($compareValue)), impact: $impact, - sensitive: true, - displayValue: $this->environmentDisplayValue($environmentVariable), + sensitive: false, + displayValue: $this->summarizeText($value), + displayFull: $this->environmentLines($environmentVariable), + diffMode: 'lines', ); } + /** + * One line per attribute so the line diff surfaces exactly which value/flags changed. + */ + private function environmentLines(EnvironmentVariable $environmentVariable): string + { + $lines = collect(); + + $value = (string) $environmentVariable->value; + if (filled($value)) { + $lines->push($value); + } + + $lines->push('Available at build: '.($environmentVariable->is_buildtime ? 'enabled' : 'disabled')); + $lines->push('Available at runtime: '.($environmentVariable->is_runtime ? 'enabled' : 'disabled')); + $lines->push('Multiline: '.($environmentVariable->is_multiline ? 'enabled' : 'disabled')); + $lines->push('Literal: '.($environmentVariable->is_literal ? 'enabled' : 'disabled')); + + return $lines->implode("\n"); + } + /** * @return array */ - private function item(string $key, string $label, mixed $value, string $impact, bool $sensitive = false, mixed $displayValue = null): array + private function item(string $key, string $label, mixed $value, string $impact, bool $sensitive = false, mixed $displayValue = null, ?string $displayFull = null, string $diffMode = 'default'): array { $normalizedValue = $this->normalizeValue($value); @@ -264,21 +313,28 @@ private function item(string $key, string $label, mixed $value, string $impact, 'label' => $label, 'impact' => $impact, 'sensitive' => $sensitive, + 'diff_mode' => $diffMode, 'compare_value' => $sensitive ? $this->sensitiveHash($normalizedValue) : $normalizedValue, 'display_value' => $displayValue ?? $this->displayValue($normalizedValue), + 'display_full' => $sensitive ? null : $this->expandableText($displayFull ?? $this->stringifyValue($normalizedValue)), ]; } private function environmentDisplayValue(EnvironmentVariable $environmentVariable): string { - $flags = collect([ + $flags = $this->environmentFlags($environmentVariable); + + return $flags ? "Hidden ({$flags})" : 'Hidden'; + } + + private function environmentFlags(EnvironmentVariable $environmentVariable): string + { + return collect([ $environmentVariable->is_buildtime ? 'build-time' : null, $environmentVariable->is_runtime ? 'runtime' : null, $environmentVariable->is_multiline ? 'multiline' : null, $environmentVariable->is_literal ? 'literal' : null, ])->filter()->implode(', '); - - return $flags ? "Hidden ({$flags})" : 'Hidden'; } private function sensitiveHash(mixed $value): string @@ -320,6 +376,58 @@ private function displayValue(mixed $value): string return $this->summarizeText((string) $value); } + private function stringifyValue(mixed $value): ?string + { + if ($value === null || is_bool($value)) { + return null; + } + + if (is_array($value)) { + return json_encode($value, JSON_THROW_ON_ERROR); + } + + return (string) $value; + } + + /** + * @return array|null + */ + private function decodedComposeDomains(): ?array + { + if (blank($this->application->docker_compose_domains)) { + return null; + } + + $decoded = json_decode((string) $this->application->docker_compose_domains, true); + + return is_array($decoded) ? $decoded : null; + } + + private function composeDomainsText(): ?string + { + $decoded = $this->decodedComposeDomains(); + + if (blank($decoded)) { + return null; + } + + return collect($decoded) + ->map(fn ($value, $service): string => $service.': '.(filled(data_get($value, 'domain')) ? data_get($value, 'domain') : '-')) + ->sort() + ->implode("\n"); + } + + private function decodeCustomLabels(?string $value): ?string + { + if (blank($value)) { + return null; + } + + $decoded = base64_decode($value, true); + + return $decoded === false ? $value : $decoded; + } + private function summarizeText(?string $value): string { if (blank($value)) { @@ -333,6 +441,6 @@ private function summarizeText(?string $value): string return str($value)->limit(80)." ({$lines} lines)"; } - return str($value)->limit(120)->value(); + return str($value)->limit(self::SINGLE_LINE_LIMIT)->value(); } } diff --git a/app/Services/DeploymentConfiguration/Concerns/SummarizesDiffText.php b/app/Services/DeploymentConfiguration/Concerns/SummarizesDiffText.php new file mode 100644 index 000000000..6960a8f1b --- /dev/null +++ b/app/Services/DeploymentConfiguration/Concerns/SummarizesDiffText.php @@ -0,0 +1,32 @@ + self::SINGLE_LINE_LIMIT) { + return $value; + } + + return null; + } +} diff --git a/app/Services/DeploymentConfiguration/ConfigurationDiff.php b/app/Services/DeploymentConfiguration/ConfigurationDiff.php index e8a206025..3f0477ba3 100644 --- a/app/Services/DeploymentConfiguration/ConfigurationDiff.php +++ b/app/Services/DeploymentConfiguration/ConfigurationDiff.php @@ -2,8 +2,6 @@ namespace App\Services\DeploymentConfiguration; -use Illuminate\Support\Collection; - class ConfigurationDiff { /** @@ -81,20 +79,6 @@ public function changes(): array return $this->changes; } - /** - * @return array>}> - */ - public function groupedChanges(): array - { - return collect($this->changes) - ->groupBy('section') - ->map(fn (Collection $changes): array => [ - 'label' => (string) data_get($changes->first(), 'section_label', str((string) $changes->keys()->first())->headline()), - 'changes' => $changes->values()->all(), - ]) - ->all(); - } - /** * @return array{changed: bool, count: int, requires_build: bool, requires_redeploy: bool, legacy_fallback: bool, changes: array>} */ diff --git a/app/Services/DeploymentConfiguration/ConfigurationDiffer.php b/app/Services/DeploymentConfiguration/ConfigurationDiffer.php index b101b9d5b..e9707edbe 100644 --- a/app/Services/DeploymentConfiguration/ConfigurationDiffer.php +++ b/app/Services/DeploymentConfiguration/ConfigurationDiffer.php @@ -2,8 +2,21 @@ namespace App\Services\DeploymentConfiguration; +use App\Services\DeploymentConfiguration\Concerns\SummarizesDiffText; + class ConfigurationDiffer { + use SummarizesDiffText; + + /** + * Keys that must never be reported as changes. The generated docker_compose + * is re-rendered from git on every parse, so legacy snapshots that still + * contain it would otherwise flag a permanent change after it was dropped. + * + * @var array + */ + private const IGNORED_KEYS = ['build.docker_compose']; + /** * @param array $previousSnapshot * @param array $currentSnapshot @@ -16,6 +29,10 @@ public function diff(array $previousSnapshot, array $currentSnapshot): Configura $changes = []; foreach ($keys as $key) { + if (in_array($key, self::IGNORED_KEYS, true)) { + continue; + } + $previous = $previousItems[$key] ?? null; $current = $currentItems[$key] ?? null; @@ -27,6 +44,37 @@ public function diff(array $previousSnapshot, array $currentSnapshot): Configura $sensitive = (bool) data_get($item, 'sensitive', false); $type = $previous === null ? 'added' : ($current === null ? 'removed' : 'changed'); $displaySummary = $sensitive && $type === 'changed' ? 'Changed' : null; + $diffMode = data_get($item, 'diff_mode', 'default'); + + $oldFull = null; + $newFull = null; + + if ($sensitive) { + $oldDisplay = $previous === null ? '-' : '••••••••'; + $newDisplay = $current === null ? '-' : '••••••••'; + } elseif ($diffMode === 'lines' && $type === 'changed') { + [$oldDisplay, $newDisplay] = $this->changedLines( + data_get($previous, 'display_full'), + data_get($current, 'display_full'), + ); + + // No line-level difference (e.g. only reordering) — fall back to the summary. + if ($oldDisplay === '-' && $newDisplay === '-') { + $oldDisplay = data_get($previous, 'display_value', '-'); + $newDisplay = data_get($current, 'display_value', '-'); + } + + // Expansion reveals the full changed lines, not the entire value. + $oldFull = $this->expandableText($oldDisplay); + $newFull = $this->expandableText($newDisplay); + } else { + $oldDisplay = data_get($previous, 'display_value', '-'); + $newDisplay = data_get($current, 'display_value', '-'); + $oldFull = data_get($previous, 'display_full'); + $newFull = data_get($current, 'display_full'); + } + + $expandable = ! $sensitive && (filled($oldFull) || filled($newFull)); $changes[] = [ 'key' => $key, @@ -37,14 +85,54 @@ public function diff(array $previousSnapshot, array $currentSnapshot): Configura 'impact' => data_get($item, 'impact', 'redeploy'), 'sensitive' => $sensitive, 'display_summary' => $displaySummary, - 'old_display_value' => $sensitive ? ($previous === null ? '-' : '••••••••') : data_get($previous, 'display_value', '-'), - 'new_display_value' => $sensitive ? ($current === null ? '-' : '••••••••') : data_get($current, 'display_value', '-'), + 'old_display_value' => $oldDisplay, + 'new_display_value' => $newDisplay, + 'old_full_value' => $oldFull, + 'new_full_value' => $newFull, + 'expandable' => $expandable, ]; } return ConfigurationDiff::fromChanges($changes); } + /** + * Reduce two multi-line values to only the lines that differ, so the modal + * shows just the changed container labels instead of the whole block. + * + * @return array{0: string, 1: string} + */ + private function changedLines(?string $old, ?string $new): array + { + $oldLines = $this->textLines($old); + $newLines = $this->textLines($new); + + $removed = array_values(array_diff($oldLines, $newLines)); + $added = array_values(array_diff($newLines, $oldLines)); + + return [ + $removed === [] ? '-' : implode("\n", $removed), + $added === [] ? '-' : implode("\n", $added), + ]; + } + + /** + * @return array + */ + private function textLines(?string $value): array + { + if (blank($value)) { + return []; + } + + // Keep leading indentation (meaningful for YAML/compose), drop trailing whitespace. + return collect(preg_split('/\r\n|\r|\n/', (string) $value)) + ->map(fn (string $line): string => rtrim($line)) + ->filter(fn (string $line): bool => trim($line) !== '') + ->values() + ->all(); + } + /** * @param array $snapshot * @return array> diff --git a/app/Support/ValidationPatterns.php b/app/Support/ValidationPatterns.php index 7e3974dd7..bdb8654b9 100644 --- a/app/Support/ValidationPatterns.php +++ b/app/Support/ValidationPatterns.php @@ -35,6 +35,17 @@ class ValidationPatterns */ public const DOCKER_TARGET_PATTERN = '/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/'; + /** + * Pattern for SSH usernames. + * Allows alphanumeric characters, dots, hyphens, and underscores. + */ + public const SERVER_USERNAME_PATTERN = '/^[a-zA-Z0-9._-]+$/'; + + /** + * Pattern for removing characters not allowed in SSH usernames. + */ + public const INVALID_SERVER_USERNAME_CHARACTERS_PATTERN = '/[^A-Za-z0-9.\-_]/'; + /** * Token-aware pattern for shell-safe command strings (docker compose commands, docker run options). * @@ -283,6 +294,28 @@ public static function databaseIdentifierRules(bool $required = true, int $minLe return $rules; } + /** + * Get validation rules for SSH username fields. + */ + public static function serverUsernameRules(bool $required = true): array + { + return [ + $required ? 'required' : 'nullable', + 'string', + 'regex:'.self::SERVER_USERNAME_PATTERN, + ]; + } + + /** + * Get validation messages for SSH username fields. + */ + public static function serverUsernameMessages(string $field = 'user', string $label = 'User'): array + { + return [ + "{$field}.regex" => "The {$label} may only contain letters, numbers, dots, hyphens, and underscores.", + ]; + } + /** * Get validation messages for database identifier fields. */ diff --git a/app/Traits/SshRetryable.php b/app/Traits/SshRetryable.php index 2092dc5f3..37303c7e6 100644 --- a/app/Traits/SshRetryable.php +++ b/app/Traits/SshRetryable.php @@ -40,6 +40,7 @@ protected function isRetryableSshError(string $errorOutput): bool 'Remote host closed connection', 'Authentication failed', 'Too many authentication failures', + 'SSH command failed with exit code: 255', ]; $lowerErrorOutput = strtolower($errorOutput); diff --git a/bootstrap/helpers/docker.php b/bootstrap/helpers/docker.php index 5905ed3c1..2cf159bfd 100644 --- a/bootstrap/helpers/docker.php +++ b/bootstrap/helpers/docker.php @@ -86,7 +86,7 @@ function format_docker_command_output_to_json($rawOutput): Collection return $outputLines ->reject(fn ($line) => empty($line)) ->map(fn ($outputLine) => json_decode($outputLine, true, flags: JSON_THROW_ON_ERROR)); - } catch (\Throwable) { + } catch (Throwable) { return collect([]); } } @@ -123,7 +123,7 @@ function format_docker_envs_to_json($rawOutput) return [$env[0] => $env[1]]; }); - } catch (\Throwable) { + } catch (Throwable) { return collect([]); } } @@ -255,12 +255,12 @@ function defaultLabels($id, $name, string $projectName, string $resourceName, st function generateServiceSpecificFqdns(ServiceApplication|Application $resource) { - if ($resource->getMorphClass() === \App\Models\ServiceApplication::class) { + if ($resource->getMorphClass() === ServiceApplication::class) { $uuid = data_get($resource, 'uuid'); $server = data_get($resource, 'service.server'); $environment_variables = data_get($resource, 'service.environment_variables'); $type = $resource->serviceType(); - } elseif ($resource->getMorphClass() === \App\Models\Application::class) { + } elseif ($resource->getMorphClass() === Application::class) { $uuid = data_get($resource, 'uuid'); $server = data_get($resource, 'destination.server'); $environment_variables = data_get($resource, 'environment_variables'); @@ -641,7 +641,7 @@ function fqdnLabelsForTraefik(string $uuid, Collection $domains, bool $is_force_ } } } - } catch (\Throwable) { + } catch (Throwable) { continue; } } @@ -1000,6 +1000,7 @@ function convertDockerRunToCompose(?string $custom_docker_run_options = null) '--ulimit', '--device', '--shm-size', + '--dns', ]); $mapping = collect([ '--cap-add' => 'cap_add', @@ -1013,6 +1014,7 @@ function convertDockerRunToCompose(?string $custom_docker_run_options = null) '--ip' => 'ip', '--ip6' => 'ip6', '--shm-size' => 'shm_size', + '--dns' => 'dns', '--gpus' => 'gpus', '--hostname' => 'hostname', '--entrypoint' => 'entrypoint', @@ -1219,7 +1221,7 @@ function validateComposeFile(string $compose, int $server_id): string|Throwable $server = Server::ownedByCurrentTeam()->find($server_id); try { if (! $server) { - throw new \Exception('Server not found'); + throw new Exception('Server not found'); } $yaml_compose = Yaml::parse($compose); @@ -1235,7 +1237,7 @@ function validateComposeFile(string $compose, int $server_id): string|Throwable ], $server); return 'OK'; - } catch (\Throwable $e) { + } catch (Throwable $e) { return $e->getMessage(); } finally { if (filled($server)) { @@ -1351,10 +1353,10 @@ function escapeBashDoubleQuoted(?string $value): string * Generate Docker build arguments from environment variables collection * Returns only keys (no values) since values are sourced from environment via export * - * @param \Illuminate\Support\Collection|array $variables Collection of variables with 'key', 'value', and optionally 'is_multiline' - * @return \Illuminate\Support\Collection Collection of formatted --build-arg strings (keys only) + * @param Collection|array $variables Collection of variables with 'key', 'value', and optionally 'is_multiline' + * @return Collection Collection of formatted --build-arg strings (keys only) */ -function generateDockerBuildArgs($variables): \Illuminate\Support\Collection +function generateDockerBuildArgs($variables): Collection { $variables = collect($variables); @@ -1369,7 +1371,7 @@ function generateDockerBuildArgs($variables): \Illuminate\Support\Collection /** * Generate Docker environment flags from environment variables collection * - * @param \Illuminate\Support\Collection|array $variables Collection of variables with 'key', 'value', and optionally 'is_multiline' + * @param Collection|array $variables Collection of variables with 'key', 'value', and optionally 'is_multiline' * @return string Space-separated environment flags */ function generateDockerEnvFlags($variables): string diff --git a/bootstrap/helpers/github.php b/bootstrap/helpers/github.php index 4a61960fb..0ec76f6fa 100644 --- a/bootstrap/helpers/github.php +++ b/bootstrap/helpers/github.php @@ -4,6 +4,7 @@ use App\Models\GitlabApp; use Carbon\Carbon; use Carbon\CarbonImmutable; +use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Http; use Illuminate\Support\Str; use Lcobucci\JWT\Encoding\ChainedFormatter; @@ -20,7 +21,7 @@ function generateGithubToken(GithubApp $source, string $type) $timeDiff = abs($serverTime->diffInSeconds($githubTime)); if ($timeDiff > 50) { - throw new \Exception( + throw new Exception( 'System time is out of sync with GitHub API time:
'. '- System time: '.$serverTime->format('Y-m-d H:i:s').' UTC
'. '- GitHub time: '.$githubTime->format('Y-m-d H:i:s').' UTC
'. @@ -60,7 +61,7 @@ function generateGithubToken(GithubApp $source, string $type) return $response->json()['token']; })(), - default => throw new \InvalidArgumentException("Unsupported token type: {$type}") + default => throw new InvalidArgumentException("Unsupported token type: {$type}") }; } @@ -77,11 +78,11 @@ function generateGithubJwt(GithubApp $source) function githubApi(GithubApp|GitlabApp|null $source, string $endpoint, string $method = 'get', ?array $data = null, bool $throwError = true) { if (is_null($source)) { - throw new \Exception('Source is required for API calls'); + throw new Exception('Source is required for API calls'); } if ($source->getMorphClass() !== GithubApp::class) { - throw new \InvalidArgumentException("Unsupported source type: {$source->getMorphClass()}"); + throw new InvalidArgumentException("Unsupported source type: {$source->getMorphClass()}"); } if ($source->is_public) { @@ -100,7 +101,7 @@ function githubApi(GithubApp|GitlabApp|null $source, string $endpoint, string $m $errorMessage = data_get($response->json(), 'message', 'no error message found'); $remainingCalls = $response->header('X-RateLimit-Remaining', '0'); - throw new \Exception( + throw new Exception( 'GitHub API call failed:
'. "Error: {$errorMessage}
". 'Rate Limit Status:
'. @@ -116,13 +117,19 @@ function githubApi(GithubApp|GitlabApp|null $source, string $endpoint, string $m ]; } -function getInstallationPath(GithubApp $source) +function getInstallationPath(GithubApp $source): string { - $github = GithubApp::where('uuid', $source->uuid)->first(); - $name = str(Str::kebab($github->name)); - $installation_path = $github->html_url === 'https://github.com' ? 'apps' : 'github-apps'; + $name = str(Str::kebab($source->name)); + $installation_path = $source->html_url === 'https://github.com' ? 'apps' : 'github-apps'; + $state = Str::random(64); - return "$github->html_url/$installation_path/$name/installations/new"; + Cache::put('github-app-setup-state:'.hash('sha256', $state), [ + 'action' => 'install', + 'github_app_id' => $source->id, + 'team_id' => $source->team_id, + ], now()->addMinutes(60)); + + return "$source->html_url/$installation_path/$name/installations/new?".http_build_query(['state' => $state]); } function getPermissionsPath(GithubApp $source) diff --git a/bootstrap/helpers/proxy.php b/bootstrap/helpers/proxy.php index d443c84a4..699704393 100644 --- a/bootstrap/helpers/proxy.php +++ b/bootstrap/helpers/proxy.php @@ -4,6 +4,7 @@ use App\Enums\ProxyTypes; use App\Models\Application; use App\Models\Server; +use Illuminate\Support\Collection; use Illuminate\Support\Facades\Log; use Symfony\Component\Yaml\Yaml; @@ -137,7 +138,7 @@ function connectProxyToNetworks(Server $server) * This must be called BEFORE docker compose up since the compose file declares networks as external. * * @param Server $server The server to ensure networks on - * @return \Illuminate\Support\Collection Commands to create networks if they don't exist + * @return Collection Commands to create networks if they don't exist */ function ensureProxyNetworksExist(Server $server) { @@ -215,7 +216,7 @@ function extractCustomProxyCommands(Server $server, string $existing_config): ar $custom_commands[] = $command; } } - } catch (\Exception $e) { + } catch (Exception $e) { // If we can't parse the config, return empty array // Silently fail to avoid breaking the proxy regeneration } @@ -436,7 +437,7 @@ function getExactTraefikVersionFromContainer(Server $server): ?string Log::debug("getExactTraefikVersionFromContainer: Server '{$server->name}' (ID: {$server->id}) - Could not detect exact version"); return null; - } catch (\Exception $e) { + } catch (Exception $e) { Log::error("getExactTraefikVersionFromContainer: Server '{$server->name}' (ID: {$server->id}) - Error: ".$e->getMessage()); return null; @@ -483,7 +484,7 @@ function getTraefikVersionFromDockerCompose(Server $server): ?string Log::debug("getTraefikVersionFromDockerCompose: Server '{$server->name}' (ID: {$server->id}) - Image format doesn't match expected pattern: {$image}"); return null; - } catch (\Exception $e) { + } catch (Exception $e) { Log::error("getTraefikVersionFromDockerCompose: Server '{$server->name}' (ID: {$server->id}) - Error: ".$e->getMessage()); return null; diff --git a/bootstrap/helpers/remoteProcess.php b/bootstrap/helpers/remoteProcess.php index 60427377b..0f3a7c4dd 100644 --- a/bootstrap/helpers/remoteProcess.php +++ b/bootstrap/helpers/remoteProcess.php @@ -200,6 +200,7 @@ function decode_remote_command_output(?ApplicationDeploymentQueue $application_d } $application = Application::find(data_get($application_deployment_queue, 'application_id')); $is_debug_enabled = data_get($application, 'settings.is_debug_enabled'); + $serverTimezone = getServerTimezone(data_get($application, 'destination.server')); // Members should never see debug logs, even if an admin enabled debug mode if ($is_debug_enabled && auth()->check() && auth()->user()->isMember()) { @@ -245,8 +246,14 @@ function decode_remote_command_output(?ApplicationDeploymentQueue $application_d return $formatted ->sortBy(fn ($i) => data_get($i, 'order')) - ->map(function ($i) { - data_set($i, 'timestamp', Carbon::parse(data_get($i, 'timestamp'))->format('Y-M-d H:i:s.u')); + ->map(function ($i) use ($serverTimezone) { + $timestamp = Carbon::parse(data_get($i, 'timestamp')); + try { + $timestamp->setTimezone($serverTimezone); + } catch (Exception) { + $timestamp->setTimezone('UTC'); + } + data_set($i, 'timestamp', $timestamp->format('Y-M-d H:i:s.u')); return $i; }) diff --git a/bootstrap/helpers/shared.php b/bootstrap/helpers/shared.php index 08af8ee42..f2b672fef 100644 --- a/bootstrap/helpers/shared.php +++ b/bootstrap/helpers/shared.php @@ -1865,15 +1865,15 @@ function isBase64Encoded($strValue) { return base64_encode(base64_decode($strValue, true)) === $strValue; } -function customApiValidator(Collection|array $item, array $rules) +function customApiValidator(Collection|array $item, array $rules, array $messages = []) { if (is_array($item)) { $item = collect($item); } - return Validator::make($item->toArray(), $rules, [ + return Validator::make($item->toArray(), $rules, array_merge([ 'required' => 'This field is required.', - ]); + ], $messages)); } function parseDockerComposeFile(Service|Application $resource, bool $isNew = false, int $pull_request_id = 0, ?int $preview_id = null) { diff --git a/config/constants.php b/config/constants.php index fd66a682a..a01669673 100644 --- a/config/constants.php +++ b/config/constants.php @@ -68,6 +68,13 @@ '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, diff --git a/config/purify.php b/config/purify.php index a5dcabb92..3d181d6eb 100644 --- a/config/purify.php +++ b/config/purify.php @@ -1,5 +1,6 @@ [ 'driver' => env('CACHE_STORE', env('CACHE_DRIVER', 'file')), - 'cache' => \Stevebauman\Purify\Cache\CacheDefinitionCache::class, + 'cache' => CacheDefinitionCache::class, ], // 'serializer' => [ diff --git a/database/migrations/2026_03_26_000000_make_ports_exposes_nullable_in_applications_table.php b/database/migrations/2026_03_26_000000_make_ports_exposes_nullable_in_applications_table.php new file mode 100644 index 000000000..ac7b5cb55 --- /dev/null +++ b/database/migrations/2026_03_26_000000_make_ports_exposes_nullable_in_applications_table.php @@ -0,0 +1,22 @@ +string('ports_exposes')->nullable()->change(); + }); + } + + public function down(): void + { + Schema::table('applications', function (Blueprint $table) { + $table->string('ports_exposes')->nullable(false)->default('')->change(); + }); + } +}; diff --git a/database/migrations/2026_03_27_000000_add_max_restart_count_to_applications.php b/database/migrations/2026_03_27_000000_add_max_restart_count_to_applications.php new file mode 100644 index 000000000..578959c9a --- /dev/null +++ b/database/migrations/2026_03_27_000000_add_max_restart_count_to_applications.php @@ -0,0 +1,22 @@ +integer('max_restart_count')->default(10)->after('restart_count'); + }); + } + + public function down(): void + { + Schema::table('applications', function (Blueprint $blueprint) { + $blueprint->dropColumn('max_restart_count'); + }); + } +}; diff --git a/database/migrations/2026_05_29_000000_encrypt_application_deployment_configuration_columns.php b/database/migrations/2026_05_29_000000_encrypt_application_deployment_configuration_columns.php new file mode 100644 index 000000000..95500cfef --- /dev/null +++ b/database/migrations/2026_05_29_000000_encrypt_application_deployment_configuration_columns.php @@ -0,0 +1,31 @@ + 'COOLIFY_SERVER_UUID', diff --git a/docker/testing-host/Dockerfile b/docker/testing-host/Dockerfile index fdad3cc41..43b16981a 100644 --- a/docker/testing-host/Dockerfile +++ b/docker/testing-host/Dockerfile @@ -20,9 +20,22 @@ ENV PATH="/host/usr/local/sbin:/host/usr/local/bin:/host/usr/sbin:/host/usr/bin: RUN apt update && apt -y install openssh-client openssh-server curl wget git jq jc RUN mkdir -p ~/.docker/cli-plugins -RUN curl -sSL https://github.com/docker/buildx/releases/download/v${DOCKER_BUILDX_VERSION}/buildx-v${DOCKER_BUILDX_VERSION}.linux-amd64 -o ~/.docker/cli-plugins/docker-buildx -RUN curl -sSL https://github.com/docker/compose/releases/download/v${DOCKER_COMPOSE_VERSION}/docker-compose-linux-x86_64 -o ~/.docker/cli-plugins/docker-compose -RUN (curl -sSL https://download.docker.com/linux/static/stable/x86_64/docker-${DOCKER_VERSION}.tgz | tar -C /usr/bin/ --no-same-owner -xzv --strip-components=1 docker/docker) + +# Download architecture-matched Docker CLI, buildx, and compose binaries. +# This image is published as a multi-arch manifest (amd64 + arm64), so the +# downloaded binaries must match TARGETPLATFORM or they fail with "exec format error" +# when the container runs on the other architecture. +RUN if [ "${TARGETPLATFORM}" = "linux/amd64" ]; then \ + curl -sSL https://github.com/docker/buildx/releases/download/v${DOCKER_BUILDX_VERSION}/buildx-v${DOCKER_BUILDX_VERSION}.linux-amd64 -o ~/.docker/cli-plugins/docker-buildx && \ + curl -sSL https://github.com/docker/compose/releases/download/v${DOCKER_COMPOSE_VERSION}/docker-compose-linux-x86_64 -o ~/.docker/cli-plugins/docker-compose && \ + (curl -sSL https://download.docker.com/linux/static/stable/x86_64/docker-${DOCKER_VERSION}.tgz | tar -C /usr/bin/ --no-same-owner -xzv --strip-components=1 docker/docker); \ + elif [ "${TARGETPLATFORM}" = "linux/arm64" ]; then \ + curl -sSL https://github.com/docker/buildx/releases/download/v${DOCKER_BUILDX_VERSION}/buildx-v${DOCKER_BUILDX_VERSION}.linux-arm64 -o ~/.docker/cli-plugins/docker-buildx && \ + curl -sSL https://github.com/docker/compose/releases/download/v${DOCKER_COMPOSE_VERSION}/docker-compose-linux-aarch64 -o ~/.docker/cli-plugins/docker-compose && \ + (curl -sSL https://download.docker.com/linux/static/stable/aarch64/docker-${DOCKER_VERSION}.tgz | tar -C /usr/bin/ --no-same-owner -xzv --strip-components=1 docker/docker); \ + else \ + echo "Unsupported TARGETPLATFORM: ${TARGETPLATFORM}" && exit 1; \ + fi RUN chmod +x ~/.docker/cli-plugins/docker-compose /usr/bin/docker /root/.docker/cli-plugins/docker-buildx diff --git a/openapi.json b/openapi.json index f3bda9ab8..ca445ade0 100644 --- a/openapi.json +++ b/openapi.json @@ -79,8 +79,7 @@ "environment_uuid", "git_repository", "git_branch", - "build_pack", - "ports_exposes" + "build_pack" ], "properties": { "project_uuid": { @@ -526,8 +525,7 @@ "github_app_uuid", "git_repository", "git_branch", - "build_pack", - "ports_exposes" + "build_pack" ], "properties": { "project_uuid": { @@ -977,8 +975,7 @@ "private_key_uuid", "git_repository", "git_branch", - "build_pack", - "ports_exposes" + "build_pack" ], "properties": { "project_uuid": { @@ -1775,8 +1772,7 @@ "server_uuid", "environment_name", "environment_uuid", - "docker_registry_image_name", - "ports_exposes" + "docker_registry_image_name" ], "properties": { "project_uuid": { diff --git a/openapi.yaml b/openapi.yaml index fae8d7110..6182cacd3 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -59,7 +59,6 @@ paths: - git_repository - git_branch - build_pack - - ports_exposes properties: project_uuid: type: string @@ -344,7 +343,6 @@ paths: - git_repository - git_branch - build_pack - - ports_exposes properties: project_uuid: type: string @@ -632,7 +630,6 @@ paths: - git_repository - git_branch - build_pack - - ports_exposes properties: project_uuid: type: string @@ -1141,7 +1138,6 @@ paths: - environment_name - environment_uuid - docker_registry_image_name - - ports_exposes properties: project_uuid: type: string diff --git a/other/nightly/install.sh b/other/nightly/install.sh index 4a91f7b1b..028652d80 100755 --- a/other/nightly/install.sh +++ b/other/nightly/install.sh @@ -781,10 +781,12 @@ curl -fsSL -L $CDN/.env.production -o /data/coolify/source/.env.production & PID3=$! curl -fsSL -L $CDN/upgrade.sh -o /data/coolify/source/upgrade.sh & PID4=$! +curl -fsSL -L $CDN/upgrade-postgres.sh -o /data/coolify/source/upgrade-postgres.sh & +PID5=$! # Wait for all downloads to complete and check for errors DOWNLOAD_FAILED=false -for PID in $PID1 $PID2 $PID3 $PID4; do +for PID in $PID1 $PID2 $PID3 $PID4 $PID5; do if ! wait $PID; then DOWNLOAD_FAILED=true fi @@ -795,6 +797,7 @@ if [ "$DOWNLOAD_FAILED" = true ]; then exit 1 fi +chmod +x /data/coolify/source/upgrade.sh /data/coolify/source/upgrade-postgres.sh log "All configuration files downloaded successfully" echo " Done." diff --git a/other/nightly/upgrade-postgres.sh b/other/nightly/upgrade-postgres.sh new file mode 100755 index 000000000..09065875e --- /dev/null +++ b/other/nightly/upgrade-postgres.sh @@ -0,0 +1,405 @@ +#!/bin/bash +## Explicit Coolify internal PostgreSQL major-version migrator. +## This script is intentionally not run by upgrade.sh automatically. + +set -Eeuo pipefail + +SOURCE_DIR="/data/coolify/source" +ENV_FILE="${SOURCE_DIR}/.env" +BACKUP_DIR="/data/coolify/backups/internal-postgres" +OVERRIDE_FILE="${SOURCE_DIR}/docker-compose.postgres-upgrade.yml" +ROLLBACK_FILE="${SOURCE_DIR}/postgres-upgrade-rollback.env" +DATE=$(date +%Y-%m-%d-%H-%M-%S) +LOGFILE="${SOURCE_DIR}/postgres-upgrade-${DATE}.log" +COMMAND="${1:-upgrade}" +TARGET_MAJOR="${1:-18}" + +if [ "$COMMAND" = "rollback" ]; then + TARGET_MAJOR="" +else + COMMAND="upgrade" +fi + +TARGET_IMAGE="${COOLIFY_POSTGRES_TARGET_IMAGE:-postgres:${TARGET_MAJOR}-alpine}" +TARGET_VOLUME="${COOLIFY_POSTGRES_TARGET_VOLUME:-coolify-db-pg${TARGET_MAJOR}}" +TEMP_CONTAINER="coolify-db-pg${TARGET_MAJOR:-rollback}-restore-${DATE}" +DUMP_FILE="${BACKUP_DIR}/postgres-upgrade-${DATE}.sql.gz" + +log() { + echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1" | tee -a "$LOGFILE" +} + +fail() { + log "ERROR: $1" + exit 1 +} + +usage() { + cat < + $0 rollback + +Examples: + $0 18 + $0 rollback + +Environment overrides: + COOLIFY_POSTGRES_TARGET_IMAGE=postgres:18-alpine + COOLIFY_POSTGRES_TARGET_VOLUME=coolify-db-pg18 +EOF +} + +cleanup() { + docker rm -f "$TEMP_CONTAINER" >/dev/null 2>&1 || true +} +trap cleanup EXIT + +get_env_var() { + local key="$1" + local fallback="${2:-}" + local value + + value=$(grep -E "^${key}=" "$ENV_FILE" 2>/dev/null | tail -n 1 | cut -d '=' -f 2- || true) + value="${value%\"}" + value="${value#\"}" + value="${value%\'}" + value="${value#\'}" + + if [ -z "$value" ]; then + printf '%s' "$fallback" + else + printf '%s' "$value" + fi +} + +wait_for_postgres() { + local container="$1" + local user="$2" + local database="$3" + local attempts=60 + + for _ in $(seq 1 "$attempts"); do + if docker exec "$container" pg_isready -U "$user" -d "$database" >/dev/null 2>&1; then + return 0 + fi + sleep 2 + done + + return 1 +} + +compose_files() { + printf -- '-f %s/docker-compose.yml -f %s/docker-compose.prod.yml ' "$SOURCE_DIR" "$SOURCE_DIR" + + if [ -f "${SOURCE_DIR}/docker-compose.custom.yml" ]; then + printf -- '-f %s/docker-compose.custom.yml ' "$SOURCE_DIR" + fi + + if [ -f "$OVERRIDE_FILE" ]; then + printf -- '-f %s ' "$OVERRIDE_FILE" + fi +} + +validate_target_major() { + case "$TARGET_MAJOR" in + ''|*[!0-9]*) + usage + fail "Target major version must be numeric. Example: $0 18" + ;; + esac + + if [ "$TARGET_MAJOR" -lt 10 ]; then + fail "Target major version must be 10 or higher." + fi +} + +mount_path_for_major() { + local major="$1" + + if [ "$major" -ge 18 ]; then + printf '%s' '/var/lib/postgresql' + else + printf '%s' '/var/lib/postgresql/data' + fi +} + +current_postgres_mount_name() { + docker inspect coolify-db --format '{{range .Mounts}}{{if or (eq .Destination "/var/lib/postgresql/data") (eq .Destination "/var/lib/postgresql")}}{{.Name}}{{end}}{{end}}' 2>/dev/null +} + +current_postgres_mount_path() { + docker inspect coolify-db --format '{{range .Mounts}}{{if or (eq .Destination "/var/lib/postgresql/data") (eq .Destination "/var/lib/postgresql")}}{{.Destination}}{{end}}{{end}}' 2>/dev/null +} + +current_postgres_image() { + docker inspect coolify-db --format '{{.Config.Image}}' 2>/dev/null +} + +current_coolify_image_tag() { + local image + local image_without_digest + local last_segment + + image=$(docker inspect coolify --format '{{.Config.Image}}' 2>/dev/null || true) + image_without_digest="${image%@*}" + last_segment="${image_without_digest##*/}" + + if [[ "$last_segment" == *:* ]]; then + printf '%s' "${last_segment##*:}" + fi +} + +write_override_file() { + local image="$1" + local volume="$2" + local mount_path="$3" + + cat > "$OVERRIDE_FILE" < "$ROLLBACK_FILE" </dev/null 2>&1 || fail "Docker is required." + docker info >/dev/null 2>&1 || fail "Docker daemon is not reachable." +} + +rollback_postgres() { + validate_common_requirements + + [ -f "$ROLLBACK_FILE" ] || fail "Missing rollback metadata file: ${ROLLBACK_FILE}" + + # shellcheck disable=SC1090 + . "$ROLLBACK_FILE" + + [ -n "${PREVIOUS_IMAGE:-}" ] || fail "Rollback metadata is missing PREVIOUS_IMAGE." + [ -n "${PREVIOUS_VOLUME:-}" ] || fail "Rollback metadata is missing PREVIOUS_VOLUME." + [ -n "${PREVIOUS_MOUNT_PATH:-}" ] || fail "Rollback metadata is missing PREVIOUS_MOUNT_PATH." + [ -n "${PREVIOUS_OVERRIDE_PRESENT:-}" ] || fail "Rollback metadata is missing PREVIOUS_OVERRIDE_PRESENT." + + CURRENT_COOLIFY_IMAGE_TAG=$(current_coolify_image_tag) + + log "Rolling back Coolify internal PostgreSQL." + log "Previous image: ${PREVIOUS_IMAGE}" + log "Previous volume: ${PREVIOUS_VOLUME}" + log "Previous mount path: ${PREVIOUS_MOUNT_PATH}" + log "Current Coolify image tag: ${CURRENT_COOLIFY_IMAGE_TAG:-latest}" + + docker volume inspect "$PREVIOUS_VOLUME" >/dev/null 2>&1 || fail "Previous volume '${PREVIOUS_VOLUME}' does not exist." + + log "Stopping Coolify application container before rollback." + docker stop coolify >>"$LOGFILE" 2>&1 || true + + log "Removing current coolify-db container. Current upgraded volume is kept untouched." + docker rm -f coolify-db >>"$LOGFILE" 2>&1 || true + + if [ "$PREVIOUS_OVERRIDE_PRESENT" = "true" ]; then + log "Restoring previous PostgreSQL compose override." + write_override_file "$PREVIOUS_IMAGE" "$PREVIOUS_VOLUME" "$PREVIOUS_MOUNT_PATH" + else + log "Removing PostgreSQL compose override to restore base compose configuration." + rm -f "$OVERRIDE_FILE" + fi + + log "Starting Coolify stack with rollback database volume." + start_stack "$CURRENT_COOLIFY_IMAGE_TAG" >>"$LOGFILE" 2>&1 || fail "Could not start Coolify stack after rollback. See ${LOGFILE}." + + log "Rollback completed successfully." + cat <>"$LOGFILE" 2>&1 || fail "Could not start coolify-db." + fi + + wait_for_postgres coolify-db "$DB_USERNAME" "$DB_DATABASE" || fail "Existing coolify-db is not ready." + + SERVER_VERSION_NUM=$(docker exec coolify-db psql -U "$DB_USERNAME" -d "$DB_DATABASE" -Atc 'SHOW server_version_num;' | tr -d '[:space:]') + CURRENT_MAJOR=$((SERVER_VERSION_NUM / 10000)) + PREVIOUS_VOLUME=$(current_postgres_mount_name) + PREVIOUS_MOUNT_PATH=$(current_postgres_mount_path) + PREVIOUS_IMAGE=$(current_postgres_image) + CURRENT_COOLIFY_IMAGE_TAG=$(current_coolify_image_tag) + + [ -n "$PREVIOUS_VOLUME" ] || fail "Could not detect current PostgreSQL Docker volume." + [ -n "$PREVIOUS_MOUNT_PATH" ] || fail "Could not detect current PostgreSQL mount path." + [ -n "$PREVIOUS_IMAGE" ] || fail "Could not detect current PostgreSQL image." + + if [ -f "$OVERRIDE_FILE" ]; then + PREVIOUS_OVERRIDE_PRESENT=true + else + PREVIOUS_OVERRIDE_PRESENT=false + fi + + log "Current PostgreSQL major: ${CURRENT_MAJOR}" + log "Current active volume: ${PREVIOUS_VOLUME}" + log "Current image: ${PREVIOUS_IMAGE}" + log "Current mount path: ${PREVIOUS_MOUNT_PATH}" + log "Current Coolify image tag: ${CURRENT_COOLIFY_IMAGE_TAG:-latest}" + + if [ "$CURRENT_MAJOR" -eq "$TARGET_MAJOR" ]; then + log "PostgreSQL is already on major ${TARGET_MAJOR}. Nothing to do." + exit 0 + fi + + if [ "$CURRENT_MAJOR" -gt "$TARGET_MAJOR" ]; then + fail "Downgrade from ${CURRENT_MAJOR} to ${TARGET_MAJOR} is not supported. Use '$0 rollback' to restore the previous upgrade state." + fi + + if docker volume inspect "$TARGET_VOLUME" >/dev/null 2>&1; then + fail "Target volume '${TARGET_VOLUME}' already exists. Set COOLIFY_POSTGRES_TARGET_VOLUME to a new name or remove the old failed target volume." + fi + + log "Stopping Coolify application container to prevent writes during dump." + docker stop coolify >>"$LOGFILE" 2>&1 || true + + log "Creating compressed dump at ${DUMP_FILE}." + docker exec coolify-db pg_dumpall -U "$DB_USERNAME" | gzip -c > "$DUMP_FILE" + chmod 600 "$DUMP_FILE" + + if [ ! -s "$DUMP_FILE" ]; then + fail "Dump file is empty. Aborting." + fi + + log "Creating target Docker volume '${TARGET_VOLUME}'." + docker volume create "$TARGET_VOLUME" >>"$LOGFILE" 2>&1 + + log "Pulling ${TARGET_IMAGE}." + docker pull "$TARGET_IMAGE" >>"$LOGFILE" 2>&1 + + log "Starting temporary PostgreSQL ${TARGET_MAJOR} container." + docker run -d \ + --name "$TEMP_CONTAINER" \ + --network coolify \ + -e POSTGRES_HOST_AUTH_METHOD=trust \ + -v "${TARGET_VOLUME}:${TARGET_MOUNT_PATH}" \ + "$TARGET_IMAGE" >>"$LOGFILE" 2>&1 + + wait_for_postgres "$TEMP_CONTAINER" postgres postgres || fail "Temporary PostgreSQL ${TARGET_MAJOR} container did not become ready." + + log "Restoring dump into target volume." + gunzip -c "$DUMP_FILE" | docker exec -i "$TEMP_CONTAINER" psql -U postgres -d postgres >>"$LOGFILE" 2>&1 + + log "Smoke-checking restored Coolify database." + docker exec "$TEMP_CONTAINER" psql -U "$DB_USERNAME" -d "$DB_DATABASE" -Atc 'SELECT 1;' | grep -qx '1' || fail "Restored database smoke check failed." + + log "Saving rollback metadata to ${ROLLBACK_FILE}." + write_rollback_file "$PREVIOUS_IMAGE" "$PREVIOUS_VOLUME" "$PREVIOUS_MOUNT_PATH" "$PREVIOUS_OVERRIDE_PRESENT" "$TARGET_IMAGE" "$TARGET_VOLUME" + + log "Writing Docker Compose override to ${OVERRIDE_FILE}." + write_override_file "$TARGET_IMAGE" "$TARGET_VOLUME" "$TARGET_MOUNT_PATH" + + log "Stopping temporary restore container." + docker rm -f "$TEMP_CONTAINER" >>"$LOGFILE" 2>&1 || true + + log "Stopping old coolify-db container. Previous volume '${PREVIOUS_VOLUME}' will be kept for rollback." + docker rm -f coolify-db >>"$LOGFILE" 2>&1 || true + + log "Starting Coolify stack with PostgreSQL ${TARGET_MAJOR}." + start_stack "$CURRENT_COOLIFY_IMAGE_TAG" >>"$LOGFILE" 2>&1 || fail "Could not start Coolify stack with upgraded PostgreSQL. See ${LOGFILE}." + + log "Coolify internal PostgreSQL upgrade completed successfully." + print_rollback_instructions +} + +if [ "${COOLIFY_POSTGRES_UPGRADE_SOURCE_ONLY:-false}" = "true" ] && [ "${BASH_SOURCE[0]}" != "$0" ]; then + return 0 +fi + +case "$COMMAND" in + rollback) + rollback_postgres + ;; + upgrade) + upgrade_postgres + ;; + -h|--help|help) + usage + ;; + *) + usage + fail "Unknown command: ${COMMAND}" + ;; +esac diff --git a/other/nightly/upgrade.sh b/other/nightly/upgrade.sh index a21d39e41..8ccacb8a0 100644 --- a/other/nightly/upgrade.sh +++ b/other/nightly/upgrade.sh @@ -56,6 +56,9 @@ log "Downloading docker-compose.prod.yml from ${CDN}/docker-compose.prod.yml" curl -fsSL -L $CDN/docker-compose.prod.yml -o /data/coolify/source/docker-compose.prod.yml log "Downloading .env.production from ${CDN}/.env.production" curl -fsSL -L $CDN/.env.production -o /data/coolify/source/.env.production +log "Downloading upgrade-postgres.sh from ${CDN}/upgrade-postgres.sh" +curl -fsSL -L $CDN/upgrade-postgres.sh -o /data/coolify/source/upgrade-postgres.sh +chmod +x /data/coolify/source/upgrade-postgres.sh log "Configuration files downloaded successfully" echo " Done." @@ -69,6 +72,12 @@ if [ -f /data/coolify/source/docker-compose.custom.yml ]; then log "Including custom docker-compose.yml in image extraction" fi +# Check if PostgreSQL upgrade override exists +if [ -f /data/coolify/source/docker-compose.postgres-upgrade.yml ]; then + COMPOSE_FILES="$COMPOSE_FILES -f /data/coolify/source/docker-compose.postgres-upgrade.yml" + log "Including PostgreSQL upgrade compose override in image extraction" +fi + # Get all unique images from docker compose config # LATEST_IMAGE env var is needed for image substitution in compose files IMAGES=$(LATEST_IMAGE=${LATEST_IMAGE} docker compose --env-file "$ENV_FILE" $COMPOSE_FILES config --images 2>/dev/null | sort -u) @@ -236,15 +245,18 @@ nohup bash -c " echo '============================================================' >>\"\$LOGFILE\" write_status '5' 'Starting new containers' + COMPOSE_FILES='-f /data/coolify/source/docker-compose.yml -f /data/coolify/source/docker-compose.prod.yml' if [ -f /data/coolify/source/docker-compose.custom.yml ]; then log 'Using custom docker-compose.yml' - log 'Running docker compose up with custom configuration...' - docker run -v /data/coolify/source:/data/coolify/source -v /var/run/docker.sock:/var/run/docker.sock \${DOCKER_CONFIG_MOUNT} --rm \${REGISTRY_URL:-ghcr.io}/coollabsio/coolify-helper:\${LATEST_HELPER_VERSION} bash -c \"LATEST_IMAGE=\${LATEST_IMAGE} docker compose --env-file /data/coolify/source/.env -f /data/coolify/source/docker-compose.yml -f /data/coolify/source/docker-compose.prod.yml -f /data/coolify/source/docker-compose.custom.yml up -d --remove-orphans --wait --wait-timeout 60\" >>\"\$LOGFILE\" 2>&1 - else - log 'Using standard docker-compose configuration' - log 'Running docker compose up...' - docker run -v /data/coolify/source:/data/coolify/source -v /var/run/docker.sock:/var/run/docker.sock \${DOCKER_CONFIG_MOUNT} --rm \${REGISTRY_URL:-ghcr.io}/coollabsio/coolify-helper:\${LATEST_HELPER_VERSION} bash -c \"LATEST_IMAGE=\${LATEST_IMAGE} docker compose --env-file /data/coolify/source/.env -f /data/coolify/source/docker-compose.yml -f /data/coolify/source/docker-compose.prod.yml up -d --remove-orphans --wait --wait-timeout 60\" >>\"\$LOGFILE\" 2>&1 + COMPOSE_FILES=\"\$COMPOSE_FILES -f /data/coolify/source/docker-compose.custom.yml\" fi + if [ -f /data/coolify/source/docker-compose.postgres-upgrade.yml ]; then + log 'Using PostgreSQL upgrade compose override' + COMPOSE_FILES=\"\$COMPOSE_FILES -f /data/coolify/source/docker-compose.postgres-upgrade.yml\" + fi + + log 'Running docker compose up...' + docker run -v /data/coolify/source:/data/coolify/source -v /var/run/docker.sock:/var/run/docker.sock \${DOCKER_CONFIG_MOUNT} --rm \${REGISTRY_URL:-ghcr.io}/coollabsio/coolify-helper:\${LATEST_HELPER_VERSION} bash -c \"LATEST_IMAGE=\${LATEST_IMAGE} docker compose --env-file /data/coolify/source/.env \${COMPOSE_FILES} up -d --remove-orphans --wait --wait-timeout 60\" >>\"\$LOGFILE\" 2>&1 log 'Docker compose up completed' # Final log entry diff --git a/resources/js/app.js b/resources/js/app.js index 4dcae5f8e..96085bd96 100644 --- a/resources/js/app.js +++ b/resources/js/app.js @@ -1,5 +1,13 @@ import { initializeTerminalComponent } from './terminal.js'; +// Livewire 3.5.19+ re-applies `x-cloak` to morphed elements during wire:navigate +// (via replaceHtmlAttributes). With `[x-cloak]{display:none}` on the app wrapper, +// this blanks the whole page on every navigation until Alpine re-processes it. +// Strip leftover x-cloak after each navigation; the initial-load FOUC guard stays. +document.addEventListener('livewire:navigated', () => { + document.querySelectorAll('[x-cloak]').forEach((el) => el.removeAttribute('x-cloak')); +}); + ['livewire:navigated', 'alpine:init'].forEach((event) => { document.addEventListener(event, () => { // tree-shaking diff --git a/resources/views/components/deployment/configuration-diff.blade.php b/resources/views/components/deployment/configuration-diff.blade.php index f01481057..6aac5af4d 100644 --- a/resources/views/components/deployment/configuration-diff.blade.php +++ b/resources/views/components/deployment/configuration-diff.blade.php @@ -4,7 +4,7 @@ ]) @php - $changes = collect(data_get($diff, 'changes', []))->filter(fn ($change) => data_get($change, 'key') !== 'domains.custom_labels')->values()->all(); + $changes = collect(data_get($diff, 'changes', []))->values()->all(); $count = count($changes); $requiresBuild = collect($changes)->contains(fn ($change) => data_get($change, 'impact') === 'build'); @endphp @@ -41,16 +41,63 @@
@foreach ($sectionChanges as $change) + @php + $changeKey = (string) data_get($change, 'key'); + $expandable = data_get($change, 'expandable', false); + $oldDisplay = (string) data_get($change, 'old_display_value'); + $newDisplay = (string) data_get($change, 'new_display_value'); + $oldFull = data_get($change, 'old_full_value') ?? $oldDisplay; + $newFull = data_get($change, 'new_full_value') ?? $newDisplay; + $label = (string) data_get($change, 'label'); + $labelTruncated = mb_strlen($label) > 20; + $rowExpandable = $expandable || $labelTruncated; + @endphp
-
- {{ data_get($change, 'label') }} +
+ @if ($rowExpandable) +
+ @else + {{ $label }} + @endif
-
- {{ data_get($change, 'old_display_value') }} +
+ @if ($expandable) +
+ @else +
{{ $oldDisplay }}
+ @endif
-
- {{ data_get($change, 'new_display_value') }} +
+
+ @if ($expandable) +
+ @else +
{{ $newDisplay }}
+ @endif +
+ @if ($rowExpandable) + + @endif
@endforeach diff --git a/resources/views/components/forms/copy-button.blade.php b/resources/views/components/forms/copy-button.blade.php index 12fadc595..eb3f3d8a4 100644 --- a/resources/views/components/forms/copy-button.blade.php +++ b/resources/views/components/forms/copy-button.blade.php @@ -1,7 +1,13 @@ -@props(['text']) +@props(['text', 'label' => null]) -
- +
+ @if ($label) + + @endif +
+ +
diff --git a/resources/views/components/forms/env-var-input.blade.php b/resources/views/components/forms/env-var-input.blade.php index f637425c1..976c63b29 100644 --- a/resources/views/components/forms/env-var-input.blade.php +++ b/resources/views/components/forms/env-var-input.blade.php @@ -196,26 +196,6 @@ }" @click.outside="showDropdown = false"> - @if ($type === 'password' && $allowToPeak) - - @endif - get('placeholder') }}" @if ($autofocus) autofocus @endif> + @if ($type === 'password' && $allowToPeak) + + @endif + {{-- Dropdown for suggestions --}}
+ merge(['class' => $defaultClass]) }} @required($required) + @if ($modelBinding !== 'null') wire:model={{ $modelBinding }} wire:dirty.class="[box-shadow:inset_4px_0_0_#6b16ed,inset_0_0_0_2px_#e5e5e5] dark:[box-shadow:inset_4px_0_0_#fcd452,inset_0_0_0_2px_#242424]" @endif + wire:loading.attr="disabled" + @readonly($readonly) @disabled($disabled) id="{{ $htmlId }}" + name="{{ $name }}" placeholder="{{ $attributes->get('placeholder') }}" + aria-placeholder="{{ $attributes->get('placeholder') }}" + @if ($autofocus) x-ref="autofocusInput" @endif> @if ($allowToPeak)
@else diff --git a/resources/views/components/forms/textarea.blade.php b/resources/views/components/forms/textarea.blade.php index 22c89fd72..752e67433 100644 --- a/resources/views/components/forms/textarea.blade.php +++ b/resources/views/components/forms/textarea.blade.php @@ -31,6 +31,21 @@ function handleKeydown(e) { @else @if ($type === 'password')
+ merge(['class' => $defaultClassInput]) }} @required($required) + @if ($modelBinding !== 'null') wire:model={{ $modelBinding }} wire:dirty.class="[box-shadow:inset_4px_0_0_#6b16ed,inset_0_0_0_2px_#e5e5e5] dark:[box-shadow:inset_4px_0_0_#fcd452,inset_0_0_0_2px_#242424]" @endif + wire:loading.attr="disabled" + type="{{ $type }}" @readonly($readonly) @disabled($disabled) id="{{ $htmlId }}" + name="{{ $name }}" placeholder="{{ $attributes->get('placeholder') }}" + aria-placeholder="{{ $attributes->get('placeholder') }}"> + @if ($allowToPeak)
@else diff --git a/resources/views/components/modal-input.blade.php b/resources/views/components/modal-input.blade.php index bdc9d9ac7..dc1191b44 100644 --- a/resources/views/components/modal-input.blade.php +++ b/resources/views/components/modal-input.blade.php @@ -36,32 +36,34 @@ class="relative w-auto h-auto" wire:ignore>