From 14ecadc0debb2a86975ea4d48dc6abc8ddc3ceb0 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Thu, 16 Jul 2026 14:24:12 +0200 Subject: [PATCH 01/42] fix(services): avoid reading bind-mount files when syncing volumes Stop catting remote file contents into memory for non-git bind mounts in getFilesystemVolumesFromServer; git-based volumes still load via loadStorageOnServer(). --- bootstrap/helpers/services.php | 19 +++++++++---------- tests/Unit/LocalFileVolumeContentSizeTest.php | 13 +++++++++++++ 2 files changed, 22 insertions(+), 10 deletions(-) diff --git a/bootstrap/helpers/services.php b/bootstrap/helpers/services.php index 20b184a013..e46769a15c 100644 --- a/bootstrap/helpers/services.php +++ b/bootstrap/helpers/services.php @@ -139,7 +139,7 @@ function replaceVariables(string $variable): Stringable function getFilesystemVolumesFromServer(ServiceApplication|ServiceDatabase|Application $oneService, bool $isInit = false) { try { - if ($oneService->getMorphClass() === \App\Models\Application::class) { + if ($oneService->getMorphClass() === Application::class) { $workdir = $oneService->workdir(); $server = $oneService->destination->server; } else { @@ -167,13 +167,12 @@ function getFilesystemVolumesFromServer(ServiceApplication|ServiceDatabase|Appli $isDir = instant_remote_process(["test -d $fileLocation && echo OK || echo NOK"], $server); if ($isFile === 'OK') { - // If its a file & exists - $filesystemContent = instant_remote_process(["cat $fileLocation"], $server); if ($fileVolume->is_based_on_git) { - $fileVolume->content = $filesystemContent; + $fileVolume->loadStorageOnServer(); + } else { + $fileVolume->is_directory = false; + $fileVolume->save(); } - $fileVolume->is_directory = false; - $fileVolume->save(); } elseif ($isDir === 'OK') { // If its a directory & exists $fileVolume->content = null; @@ -204,7 +203,7 @@ function getFilesystemVolumesFromServer(ServiceApplication|ServiceDatabase|Appli instant_remote_process(["mkdir -p $fileLocation"], $server); } } - } catch (\Throwable $e) { + } catch (Throwable $e) { return handleError($e); } } @@ -214,7 +213,7 @@ function updateCompose(ServiceApplication|ServiceDatabase $resource) $name = data_get($resource, 'name'); $dockerComposeRaw = data_get($resource, 'service.docker_compose_raw'); if (! $dockerComposeRaw) { - throw new \Exception('No compose file found or not a valid YAML file.'); + throw new Exception('No compose file found or not a valid YAML file.'); } $dockerCompose = Yaml::parse($dockerComposeRaw); @@ -396,7 +395,7 @@ function updateCompose(ServiceApplication|ServiceDatabase $resource) } } } - } catch (\Throwable $e) { + } catch (Throwable $e) { return handleError($e); } } @@ -495,7 +494,7 @@ function applyServiceApplicationPrerequisites(Service $service): void } } } - } catch (\Throwable $e) { + } catch (Throwable $e) { // Log error but don't throw - prerequisites are nice-to-have, not critical Log::error('Failed to apply service application prerequisites', [ 'service_id' => $service->id, diff --git a/tests/Unit/LocalFileVolumeContentSizeTest.php b/tests/Unit/LocalFileVolumeContentSizeTest.php index 1fd315884e..0ad69e28aa 100644 --- a/tests/Unit/LocalFileVolumeContentSizeTest.php +++ b/tests/Unit/LocalFileVolumeContentSizeTest.php @@ -64,3 +64,16 @@ it('exposes the too-large flag via toArray for Livewire serialization', function expect($array)->toHaveKey('is_too_large'); expect($array['is_too_large'])->toBeTrue(); }); + +it('does not read regular bind-mounted file contents while loading service settings', function () { + $helpers = file_get_contents(base_path('bootstrap/helpers/services.php')); + $filesystemSync = str($helpers) + ->after('function getFilesystemVolumesFromServer') + ->before('function updateCompose'); + + expect($filesystemSync->value()) + ->not->toContain('instant_remote_process(["cat $fileLocation"]'); + expect($filesystemSync->value()) + ->toContain('if ($fileVolume->is_based_on_git)') + ->toContain('$fileVolume->loadStorageOnServer();'); +}); From 2749f45ba6e464681929e9485730395452a246f3 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Thu, 16 Jul 2026 15:21:00 +0200 Subject: [PATCH 02/42] fix: cap remote logs, tasks, and config output sizes Bound proxy config, docker compose, scheduled task, log viewer, and dynamic proxy config reads so large remote output cannot overwhelm PHP. --- app/Actions/Proxy/GetProxyConfiguration.php | 10 ++- app/Jobs/ScheduledTaskJob.php | 12 ++- app/Livewire/Project/Shared/GetLogs.php | 39 ++++++-- .../Server/Proxy/DynamicConfigurations.php | 35 +++++++- app/Models/Application.php | 15 +++- tests/Unit/RemoteOutputSizeLimitsTest.php | 90 +++++++++++++++++++ 6 files changed, 187 insertions(+), 14 deletions(-) create mode 100644 tests/Unit/RemoteOutputSizeLimitsTest.php diff --git a/app/Actions/Proxy/GetProxyConfiguration.php b/app/Actions/Proxy/GetProxyConfiguration.php index 159f122526..7df16c52b4 100644 --- a/app/Actions/Proxy/GetProxyConfiguration.php +++ b/app/Actions/Proxy/GetProxyConfiguration.php @@ -13,6 +13,8 @@ class GetProxyConfiguration { use AsAction; + public const MAX_CONFIGURATION_SIZE_BYTES = 5 * 1024 * 1024; + public function handle(Server $server, bool $forceRegenerate = false): string { $proxyType = $server->proxyType(); @@ -98,11 +100,17 @@ class GetProxyConfiguration private function backfillFromDisk(Server $server): ?string { $proxy_path = $server->proxyPath(); + $configurationPath = escapeshellarg("$proxy_path/docker-compose.yml"); + $readLimit = self::MAX_CONFIGURATION_SIZE_BYTES + 1; $result = instant_remote_process([ "mkdir -p $proxy_path", - "cat $proxy_path/docker-compose.yml 2>/dev/null", + "if [ ! -f {$configurationPath} ]; then exit 0; elif [ \"$(wc -c < {$configurationPath})\" -gt ".self::MAX_CONFIGURATION_SIZE_BYTES." ]; then echo '__COOLIFY_PROXY_CONFIG_TOO_LARGE__'; else head -c {$readLimit} {$configurationPath}; fi", ], $server, false); + if ($result === '__COOLIFY_PROXY_CONFIG_TOO_LARGE__') { + throw new \RuntimeException('Proxy configuration exceeds the 5 MiB size limit.'); + } + if (! empty(trim($result ?? ''))) { $server->proxy->last_saved_proxy_configuration = $result; $server->save(); diff --git a/app/Jobs/ScheduledTaskJob.php b/app/Jobs/ScheduledTaskJob.php index dc11ec89e7..02f3daa3b4 100644 --- a/app/Jobs/ScheduledTaskJob.php +++ b/app/Jobs/ScheduledTaskJob.php @@ -25,6 +25,8 @@ class ScheduledTaskJob implements ShouldBeEncrypted, ShouldQueue { use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; + public const MAX_OUTPUT_SIZE_BYTES = 5 * 1024 * 1024; + /** * The number of times the job may be attempted. */ @@ -148,7 +150,8 @@ class ScheduledTaskJob implements ShouldBeEncrypted, ShouldQueue foreach ($this->containers as $containerName) { if (count($this->containers) == 1 || str_starts_with($containerName, $this->task->container.'-'.$this->resource->uuid)) { $cmd = "sh -c '".str_replace("'", "'\''", $this->task->command)."'"; - $exec = "docker exec {$containerName} {$cmd}"; + $execCommand = "docker exec {$containerName} {$cmd}"; + $exec = $this->boundedTaskCommand($execCommand); // Disable SSH multiplexing to prevent race conditions when multiple tasks run concurrently // See: https://github.com/coollabsio/coolify/issues/6736 $this->task_output = instant_remote_process([$exec], $this->server, true, false, $this->timeout, disableMultiplexing: true); @@ -204,6 +207,13 @@ class ScheduledTaskJob implements ShouldBeEncrypted, ShouldQueue } } + private function boundedTaskCommand(string $command): string + { + $maxOutputBytes = self::MAX_OUTPUT_SIZE_BYTES; + + return "output_file=\$(mktemp); trap 'rm -f \"\$output_file\"' EXIT; set +e; set -o pipefail; {$command} 2>&1 | { head -c {$maxOutputBytes} > \"\$output_file\"; if IFS= read -r -n 1 extra_byte; then printf '\n\n[... Output truncated at 5MB limit ...]' >> \"\$output_file\"; fi; cat > /dev/null; }; exit_code=\${PIPESTATUS[0]}; if [ \"\$exit_code\" -eq 0 ]; then cat \"\$output_file\"; else cat \"\$output_file\" >&2; fi; exit \$exit_code"; + } + /** * Calculate the number of seconds to wait before retrying the job. */ diff --git a/app/Livewire/Project/Shared/GetLogs.php b/app/Livewire/Project/Shared/GetLogs.php index d0121bdc51..fa575073f7 100644 --- a/app/Livewire/Project/Shared/GetLogs.php +++ b/app/Livewire/Project/Shared/GetLogs.php @@ -25,6 +25,8 @@ class GetLogs extends Component { public const MAX_LOG_LINES = 50000; + public const MAX_DISPLAY_SIZE_BYTES = 5 * 1024 * 1024; + public const MAX_DOWNLOAD_SIZE_BYTES = 50 * 1024 * 1024; // 50MB public string $outputs = ''; @@ -154,14 +156,12 @@ class GetLogs extends Component $command = parseCommandsByLineForSudo(collect($command), $this->server); $command = $command[0]; } - $sshCommand = SshMultiplexingHelper::generateSshCommand($this->server, $command); } else { $command = "docker logs -n {$this->numberOfLines} -t {$this->container}"; if ($this->server->isNonRoot()) { $command = parseCommandsByLineForSudo(collect($command), $this->server); $command = $command[0]; } - $sshCommand = SshMultiplexingHelper::generateSshCommand($this->server, $command); } } else { if ($this->server->isSwarm()) { @@ -170,22 +170,39 @@ class GetLogs extends Component $command = parseCommandsByLineForSudo(collect($command), $this->server); $command = $command[0]; } - $sshCommand = SshMultiplexingHelper::generateSshCommand($this->server, $command); } else { $command = "docker logs -n {$this->numberOfLines} {$this->container}"; if ($this->server->isNonRoot()) { $command = parseCommandsByLineForSudo(collect($command), $this->server); $command = $command[0]; } - $sshCommand = SshMultiplexingHelper::generateSshCommand($this->server, $command); } } + $command = $this->boundedLogCommand($command, self::MAX_DISPLAY_SIZE_BYTES); + $sshCommand = SshMultiplexingHelper::generateSshCommand($this->server, $command); + // Collect new logs into temporary variable first to prevent flickering // (avoids clearing output before new data is ready) // Use array accumulation + implode for O(n) instead of O(n²) string concatenation $logChunks = []; - Process::timeout(config('constants.ssh.command_timeout'))->run($sshCommand, function (string $type, string $output) use (&$logChunks) { - $logChunks[] = removeAnsiColors($output); + $accumulatedBytes = 0; + $truncated = false; + Process::timeout(config('constants.ssh.command_timeout'))->run($sshCommand, function (string $type, string $output) use (&$logChunks, &$accumulatedBytes, &$truncated) { + if ($truncated) { + return; + } + + $output = removeAnsiColors($output); + $remainingBytes = self::MAX_DISPLAY_SIZE_BYTES - $accumulatedBytes; + if (strlen($output) > $remainingBytes) { + $logChunks[] = substr($output, 0, max(0, $remainingBytes)); + $truncated = true; + + return; + } + + $logChunks[] = $output; + $accumulatedBytes += strlen($output); }); $newOutputs = implode('', $logChunks); @@ -198,6 +215,10 @@ class GetLogs extends Component })->join("\n"); } + if ($truncated) { + $newOutputs .= "\n\n[... Output truncated at 5MB limit ...]"; + } + // Only update outputs after new data is ready (atomic update prevents flicker) $this->outputs = $newOutputs; } @@ -239,6 +260,7 @@ class GetLogs extends Component $command = $command[0]; } + $command = $this->boundedLogCommand($command, self::MAX_DOWNLOAD_SIZE_BYTES); $sshCommand = SshMultiplexingHelper::generateSshCommand($this->server, $command); // Use array accumulation + implode for O(n) instead of O(n²) string concatenation @@ -287,6 +309,11 @@ class GetLogs extends Component return sanitizeLogsForExport($allLogs); } + private function boundedLogCommand(string $command, int $maxBytes): string + { + return "({$command}) 2>&1 | head -c ".($maxBytes + 1); + } + public function render() { return view('livewire.project.shared.get-logs'); diff --git a/app/Livewire/Server/Proxy/DynamicConfigurations.php b/app/Livewire/Server/Proxy/DynamicConfigurations.php index f824645aa6..6351dace86 100644 --- a/app/Livewire/Server/Proxy/DynamicConfigurations.php +++ b/app/Livewire/Server/Proxy/DynamicConfigurations.php @@ -11,6 +11,12 @@ class DynamicConfigurations extends Component { use AuthorizesRequests; + public const MAX_CONFIGURATION_FILE_SIZE_BYTES = 1024 * 1024; + + public const MAX_TOTAL_CONFIGURATION_SIZE_BYTES = 5 * 1024 * 1024; + + public const MAX_CONFIGURATION_FILES = 100; + public ?Server $server = null; public $parameters = []; @@ -44,15 +50,36 @@ class DynamicConfigurations extends Component return handleError($e, $this); } $proxy_path = $this->server->proxyPath(); - $files = instant_remote_process(["mkdir -p $proxy_path/dynamic && ls -1 {$proxy_path}/dynamic"], $this->server); + $fileLimit = self::MAX_CONFIGURATION_FILES + 1; + $files = instant_remote_process(["mkdir -p $proxy_path/dynamic && ls -1 {$proxy_path}/dynamic | head -n {$fileLimit}"], $this->server); $files = collect(explode("\n", $files))->filter(fn ($file) => ! empty($file)); $files = $files->map(fn ($file) => trim($file)); $files = $files->sort(); $contents = collect([]); - foreach ($files as $file) { + $skippedFiles = collect([]); + $totalBytes = 0; + if ($files->count() > self::MAX_CONFIGURATION_FILES) { + $skippedFiles->push('additional files'); + } + foreach ($files->take(self::MAX_CONFIGURATION_FILES) as $file) { $without_extension = str_replace('.', '|', $file); - $content = instant_remote_process(["cat {$proxy_path}/dynamic/{$file}"], $this->server); - $contents[$without_extension] = $content ?? ''; + $filePath = escapeshellarg("{$proxy_path}/dynamic/{$file}"); + $readLimit = self::MAX_CONFIGURATION_FILE_SIZE_BYTES + 1; + $content = instant_remote_process(["head -c {$readLimit} {$filePath}"], $this->server); + $content = $content ?? ''; + $contentBytes = strlen($content); + + if ($contentBytes > self::MAX_CONFIGURATION_FILE_SIZE_BYTES || $totalBytes + $contentBytes > self::MAX_TOTAL_CONFIGURATION_SIZE_BYTES) { + $skippedFiles->push($file); + + continue; + } + + $contents[$without_extension] = $content; + $totalBytes += $contentBytes; + } + if ($skippedFiles->isNotEmpty()) { + $this->dispatch('warning', 'Some dynamic configurations were not loaded because they exceed the safe display limits: '.$skippedFiles->implode(', ')); } $this->contents = $contents; $this->dispatch('$refresh'); diff --git a/app/Models/Application.php b/app/Models/Application.php index 732142b0de..6f11027171 100644 --- a/app/Models/Application.php +++ b/app/Models/Application.php @@ -119,6 +119,8 @@ class Application extends BaseModel { use ClearsGlobalSearchCache, HasConfiguration, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes; + public const MAX_DOCKER_COMPOSE_SIZE_BYTES = 5 * 1024 * 1024; + private static $parserVersion = '5'; protected $fillable = [ @@ -1936,6 +1938,9 @@ class Application extends BaseModel $workdir = rtrim($this->base_directory, '/'); $composeFile = $this->docker_compose_location; $fileList = collect([".$workdir$composeFile"]); + $composeFilePath = escapeshellarg(".$workdir$composeFile"); + $composeReadLimit = self::MAX_DOCKER_COMPOSE_SIZE_BYTES + 1; + $readComposeFile = "if [ \"$(wc -c < {$composeFilePath})\" -gt ".self::MAX_DOCKER_COMPOSE_SIZE_BYTES." ]; then echo '__COOLIFY_COMPOSE_TOO_LARGE__'; else head -c {$composeReadLimit} {$composeFilePath}; fi"; $gitRemoteStatus = $this->getGitRemoteStatus(deployment_uuid: $uuid); if (! $gitRemoteStatus['is_accessible']) { throw new RuntimeException('Failed to read Git source. Please verify repository access and try again.'); @@ -1966,7 +1971,7 @@ class Application extends BaseModel 'git sparse-checkout init', "git sparse-checkout set {$fileList->implode(' ')}", 'git read-tree -mu HEAD', - "cat .$workdir$composeFile", + $readComposeFile, ]); } else { $commands = collect([ @@ -1978,11 +1983,14 @@ class Application extends BaseModel 'git sparse-checkout init --cone', "git sparse-checkout set {$fileList->implode(' ')}", 'git read-tree -mu HEAD', - "cat .$workdir$composeFile", + $readComposeFile, ]); } try { $composeFileContent = instant_remote_process($commands, $this->destination->server); + if ($composeFileContent === '__COOLIFY_COMPOSE_TOO_LARGE__') { + throw new RuntimeException('Docker Compose file exceeds the 5 MiB size limit.'); + } } catch (\Exception $e) { // Restore original values on failure only $this->docker_compose_location = $initialDockerComposeLocation; @@ -1998,6 +2006,9 @@ class Application extends BaseModel } throw new RuntimeException('Repository does not exist. Please check your repository URL and try again.'); } + if (str($e->getMessage())->contains('exceeds the 5 MiB size limit')) { + throw $e; + } throw new RuntimeException('Failed to read the Docker Compose file from the repository.'); } finally { // Cleanup only - restoration happens in catch block diff --git a/tests/Unit/RemoteOutputSizeLimitsTest.php b/tests/Unit/RemoteOutputSizeLimitsTest.php new file mode 100644 index 0000000000..e010970bba --- /dev/null +++ b/tests/Unit/RemoteOutputSizeLimitsTest.php @@ -0,0 +1,90 @@ +toContain('MAX_CONFIGURATION_FILE_SIZE_BYTES') + ->toContain('MAX_TOTAL_CONFIGURATION_SIZE_BYTES') + ->toContain('MAX_CONFIGURATION_FILES') + ->toContain('head -c') + ->toContain('$totalBytes'); + + expect(DynamicConfigurations::MAX_CONFIGURATION_FILE_SIZE_BYTES)->toBe(1024 * 1024) + ->and(DynamicConfigurations::MAX_TOTAL_CONFIGURATION_SIZE_BYTES)->toBe(5 * 1024 * 1024) + ->and(DynamicConfigurations::MAX_CONFIGURATION_FILES)->toBe(100); +}); + +it('bounds regular log viewer output before it reaches PHP', function () { + $source = remoteOutputSource('app/Livewire/Project/Shared/GetLogs.php'); + + expect($source) + ->toContain('MAX_DISPLAY_SIZE_BYTES') + ->toContain('boundedLogCommand(') + ->toContain('[... Output truncated at'); + + $method = new ReflectionMethod(GetLogs::class, 'boundedLogCommand'); + $command = $method->invoke(new GetLogs, 'docker logs example', 100); + + expect(GetLogs::MAX_DISPLAY_SIZE_BYTES)->toBe(5 * 1024 * 1024) + ->and($command)->toBe('(docker logs example) 2>&1 | head -c 101'); +}); + +it('bounds docker compose files loaded from git before parsing', function () { + $source = remoteOutputSource('app/Models/Application.php'); + + expect($source) + ->toContain('MAX_DOCKER_COMPOSE_SIZE_BYTES') + ->toContain('MAX_DOCKER_COMPOSE_SIZE_BYTES + 1') + ->toContain('head -c'); + + expect(Application::MAX_DOCKER_COMPOSE_SIZE_BYTES)->toBe(5 * 1024 * 1024); +}); + +it('bounds scheduled task output before storing or notifying', function () { + $source = remoteOutputSource('app/Jobs/ScheduledTaskJob.php'); + + expect($source) + ->toContain('MAX_OUTPUT_SIZE_BYTES') + ->toContain('head -c {$maxOutputBytes}') + ->toContain('[... Output truncated at'); + + $reflection = new ReflectionClass(ScheduledTaskJob::class); + $method = $reflection->getMethod('boundedTaskCommand'); + $command = $method->invoke($reflection->newInstanceWithoutConstructor(), 'printf hello'); + $failureCommand = $method->invoke($reflection->newInstanceWithoutConstructor(), "bash -c 'printf failure; exit 7'"); + exec('bash -n -c '.escapeshellarg($command), $output, $exitCode); + exec('bash -c '.escapeshellarg($command), $commandOutput, $commandExitCode); + exec('bash -c '.escapeshellarg($failureCommand).' 2>&1', $failureOutput, $failureExitCode); + + expect(ScheduledTaskJob::MAX_OUTPUT_SIZE_BYTES)->toBe(5 * 1024 * 1024) + ->and($command)->toContain('head -c 5242880') + ->and($exitCode)->toBe(0) + ->and($commandExitCode)->toBe(0) + ->and(implode("\n", $commandOutput))->toBe('hello') + ->and($failureExitCode)->toBe(7) + ->and(implode("\n", $failureOutput))->toBe('failure'); +}); + +it('bounds proxy configuration backfill before storing it', function () { + $source = remoteOutputSource('app/Actions/Proxy/GetProxyConfiguration.php'); + + expect($source) + ->toContain('MAX_CONFIGURATION_SIZE_BYTES') + ->toContain('MAX_CONFIGURATION_SIZE_BYTES + 1') + ->toContain('head -c') + ->toContain('Proxy configuration exceeds'); + + expect(GetProxyConfiguration::MAX_CONFIGURATION_SIZE_BYTES)->toBe(5 * 1024 * 1024); +}); From 60129c2c4701431a18c15fe4c7b40e4f8607c07c Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Thu, 16 Jul 2026 16:02:51 +0200 Subject: [PATCH 03/42] fix: measure log size before ANSI strip; harden tasks and volumes Truncate GetLogs against raw byte length, then strip ANSI colors. Run scheduled-task docker exec with explicit sudo docker and no_sudo to avoid double sudo rewriting on non-root servers. Mark git-based file volumes as files before refreshing content from the server. --- app/Jobs/ScheduledTaskJob.php | 5 ++- app/Livewire/Project/Shared/GetLogs.php | 15 ++++--- bootstrap/helpers/services.php | 5 +-- tests/Feature/GetLogsCommandInjectionTest.php | 41 ++++++++++++++++++- tests/Unit/LocalFileVolumeContentSizeTest.php | 11 +++++ tests/Unit/RemoteOutputSizeLimitsTest.php | 20 +++++++++ 6 files changed, 83 insertions(+), 14 deletions(-) diff --git a/app/Jobs/ScheduledTaskJob.php b/app/Jobs/ScheduledTaskJob.php index 02f3daa3b4..d24f36350b 100644 --- a/app/Jobs/ScheduledTaskJob.php +++ b/app/Jobs/ScheduledTaskJob.php @@ -150,11 +150,12 @@ class ScheduledTaskJob implements ShouldBeEncrypted, ShouldQueue foreach ($this->containers as $containerName) { if (count($this->containers) == 1 || str_starts_with($containerName, $this->task->container.'-'.$this->resource->uuid)) { $cmd = "sh -c '".str_replace("'", "'\''", $this->task->command)."'"; - $execCommand = "docker exec {$containerName} {$cmd}"; + $dockerCommand = $this->server->isNonRoot() ? 'sudo docker' : 'docker'; + $execCommand = "{$dockerCommand} exec {$containerName} {$cmd}"; $exec = $this->boundedTaskCommand($execCommand); // Disable SSH multiplexing to prevent race conditions when multiple tasks run concurrently // See: https://github.com/coollabsio/coolify/issues/6736 - $this->task_output = instant_remote_process([$exec], $this->server, true, false, $this->timeout, disableMultiplexing: true); + $this->task_output = instant_remote_process([$exec], $this->server, throwError: true, no_sudo: true, timeout: $this->timeout, disableMultiplexing: true); $this->task_log->update([ 'status' => 'success', 'message' => $this->task_output, diff --git a/app/Livewire/Project/Shared/GetLogs.php b/app/Livewire/Project/Shared/GetLogs.php index fa575073f7..67a040ef77 100644 --- a/app/Livewire/Project/Shared/GetLogs.php +++ b/app/Livewire/Project/Shared/GetLogs.php @@ -192,17 +192,17 @@ class GetLogs extends Component return; } - $output = removeAnsiColors($output); $remainingBytes = self::MAX_DISPLAY_SIZE_BYTES - $accumulatedBytes; - if (strlen($output) > $remainingBytes) { - $logChunks[] = substr($output, 0, max(0, $remainingBytes)); + $outputBytes = strlen($output); + if ($outputBytes > $remainingBytes) { + $logChunks[] = removeAnsiColors(substr($output, 0, max(0, $remainingBytes))); $truncated = true; return; } - $logChunks[] = $output; - $accumulatedBytes += strlen($output); + $logChunks[] = removeAnsiColors($output); + $accumulatedBytes += $outputBytes; }); $newOutputs = implode('', $logChunks); @@ -274,20 +274,19 @@ class GetLogs extends Component return; } - $output = removeAnsiColors($output); $outputBytes = strlen($output); if ($accumulatedBytes + $outputBytes > self::MAX_DOWNLOAD_SIZE_BYTES) { $remaining = self::MAX_DOWNLOAD_SIZE_BYTES - $accumulatedBytes; if ($remaining > 0) { - $logChunks[] = substr($output, 0, $remaining); + $logChunks[] = removeAnsiColors(substr($output, 0, $remaining)); } $truncated = true; return; } - $logChunks[] = $output; + $logChunks[] = removeAnsiColors($output); $accumulatedBytes += $outputBytes; }); diff --git a/bootstrap/helpers/services.php b/bootstrap/helpers/services.php index e46769a15c..05430930b7 100644 --- a/bootstrap/helpers/services.php +++ b/bootstrap/helpers/services.php @@ -167,11 +167,10 @@ function getFilesystemVolumesFromServer(ServiceApplication|ServiceDatabase|Appli $isDir = instant_remote_process(["test -d $fileLocation && echo OK || echo NOK"], $server); if ($isFile === 'OK') { + $fileVolume->is_directory = false; + $fileVolume->save(); if ($fileVolume->is_based_on_git) { $fileVolume->loadStorageOnServer(); - } else { - $fileVolume->is_directory = false; - $fileVolume->save(); } } elseif ($isDir === 'OK') { // If its a directory & exists diff --git a/tests/Feature/GetLogsCommandInjectionTest.php b/tests/Feature/GetLogsCommandInjectionTest.php index db75f7b757..c920b94ede 100644 --- a/tests/Feature/GetLogsCommandInjectionTest.php +++ b/tests/Feature/GetLogsCommandInjectionTest.php @@ -3,6 +3,7 @@ use App\Livewire\Project\Shared\GetLogs; use App\Models\Application; use App\Models\Environment; +use App\Models\PrivateKey; use App\Models\Project; use App\Models\Server; use App\Models\StandaloneDocker; @@ -10,6 +11,8 @@ use App\Models\Team; use App\Models\User; use App\Support\ValidationPatterns; use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Process\FakeProcessResult; +use Illuminate\Support\Facades\Process; use Livewire\Attributes\Locked; use Livewire\Livewire; @@ -20,7 +23,11 @@ beforeEach(function () { $this->team = Team::factory()->create(); $this->user->teams()->attach($this->team, ['role' => 'owner']); - $this->server = Server::factory()->create(['team_id' => $this->team->id]); + $privateKey = PrivateKey::factory()->create(['team_id' => $this->team->id]); + $this->server = Server::factory()->create([ + 'team_id' => $this->team->id, + 'private_key_id' => $privateKey->id, + ]); // Server::created auto-creates a StandaloneDocker, reuse it $this->destination = StandaloneDocker::where('server_id', $this->server->id)->first(); $this->project = Project::factory()->create(['team_id' => $this->team->id]); @@ -67,6 +74,38 @@ describe('GetLogs locked properties', function () { }); describe('GetLogs Livewire action validation', function () { + test('getLogs marks ANSI-colored output truncated based on raw bytes', function () { + $this->server->settings->fill([ + 'is_reachable' => true, + 'is_usable' => true, + 'force_disabled' => false, + ])->save(); + $server = Server::with('settings')->find($this->server->id); + $output = "\e[31m".str_repeat('a', GetLogs::MAX_DISPLAY_SIZE_BYTES - 4); + + expect(strlen($output))->toBe(GetLogs::MAX_DISPLAY_SIZE_BYTES + 1); + + Process::shouldReceive('timeout')->once()->andReturnSelf(); + Process::shouldReceive('run')->andReturnUsing(function (string $command, ?callable $callback = null) use ($output): FakeProcessResult { + if ($callback) { + $callback('out', $output); + } + + return new FakeProcessResult(command: $command); + }); + + $component = new GetLogs; + $component->server = $server; + $component->resource = $this->application; + $component->container = 'test-container'; + $component->showTimeStamps = false; + $component->getLogs(true); + + expect($component->outputs) + ->toContain('[... Output truncated at 5MB limit ...]') + ->not->toContain("\e[31m"); + }); + test('getLogs rejects invalid container name', function () { // Make server functional by setting settings directly $this->server->settings->fill([ diff --git a/tests/Unit/LocalFileVolumeContentSizeTest.php b/tests/Unit/LocalFileVolumeContentSizeTest.php index 0ad69e28aa..1954539785 100644 --- a/tests/Unit/LocalFileVolumeContentSizeTest.php +++ b/tests/Unit/LocalFileVolumeContentSizeTest.php @@ -77,3 +77,14 @@ it('does not read regular bind-mounted file contents while loading service setti ->toContain('if ($fileVolume->is_based_on_git)') ->toContain('$fileVolume->loadStorageOnServer();'); }); + +it('marks git-based file volumes as files before refreshing their content', function () { + $helpers = file_get_contents(base_path('bootstrap/helpers/services.php')); + $fileBranch = str($helpers) + ->after("if (\$isFile === 'OK') {") + ->before("} elseif (\$isDir === 'OK') {"); + + expect($fileBranch->value())->toMatch( + '/\$fileVolume->is_directory = false;\s+\$fileVolume->save\(\);\s+if \(\$fileVolume->is_based_on_git\) \{/' + ); +}); diff --git a/tests/Unit/RemoteOutputSizeLimitsTest.php b/tests/Unit/RemoteOutputSizeLimitsTest.php index e010970bba..b8341dd73f 100644 --- a/tests/Unit/RemoteOutputSizeLimitsTest.php +++ b/tests/Unit/RemoteOutputSizeLimitsTest.php @@ -5,6 +5,7 @@ use App\Jobs\ScheduledTaskJob; use App\Livewire\Project\Shared\GetLogs; use App\Livewire\Server\Proxy\DynamicConfigurations; use App\Models\Application; +use App\Models\Server; function remoteOutputSource(string $path): string { @@ -77,6 +78,25 @@ it('bounds scheduled task output before storing or notifying', function () { ->and(implode("\n", $failureOutput))->toBe('failure'); }); +it('does not pass the scheduled task output wrapper through the sudo rewriter', function () { + $source = remoteOutputSource('app/Jobs/ScheduledTaskJob.php'); + $reflection = new ReflectionClass(ScheduledTaskJob::class); + $method = $reflection->getMethod('boundedTaskCommand'); + $command = $method->invoke($reflection->newInstanceWithoutConstructor(), 'sudo docker exec example true'); + $server = Mockery::mock(Server::class)->makePartial(); + $server->shouldReceive('getAttribute')->with('user')->andReturn('ubuntu'); + $rewrittenCommand = parseCommandsByLineForSudo(collect([$command]), $server)[0]; + + exec('bash -n -c '.escapeshellarg($command), $output, $exitCode); + exec('bash -n -c '.escapeshellarg($rewrittenCommand).' 2>/dev/null', $rewrittenOutput, $rewrittenExitCode); + + expect($source) + ->toContain("\$dockerCommand = \$this->server->isNonRoot() ? 'sudo docker' : 'docker'") + ->toContain('instant_remote_process([$exec], $this->server, throwError: true, no_sudo: true') + ->and($exitCode)->toBe(0) + ->and($rewrittenExitCode)->not->toBe(0); +}); + it('bounds proxy configuration backfill before storing it', function () { $source = remoteOutputSource('app/Actions/Proxy/GetProxyConfiguration.php'); From 49c8f6cb5d1850fd9c49b59f1f06f359ea0172de Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Thu, 13 Aug 2026 11:31:26 +0200 Subject: [PATCH 04/42] fix: cap remote file and task reads after size-check races Read local file volumes with a bounded head instead of cat so a file that grows after the size check cannot be fully slurped into PHP memory. Treat oversized bounded reads as too large and skip binary detection on that placeholder. For scheduled tasks, read one extra byte, then truncate and append the 5MB notice instead of relying on a one-byte peek. --- app/Jobs/ScheduledTaskJob.php | 3 +- app/Models/LocalFileVolume.php | 27 ++++++++++++-- tests/Unit/LocalFileVolumeContentSizeTest.php | 35 +++++++++++++++++++ tests/Unit/RemoteOutputSizeLimitsTest.php | 18 ++++++++-- 4 files changed, 77 insertions(+), 6 deletions(-) diff --git a/app/Jobs/ScheduledTaskJob.php b/app/Jobs/ScheduledTaskJob.php index d24f36350b..f7bd5f933d 100644 --- a/app/Jobs/ScheduledTaskJob.php +++ b/app/Jobs/ScheduledTaskJob.php @@ -211,8 +211,9 @@ class ScheduledTaskJob implements ShouldBeEncrypted, ShouldQueue private function boundedTaskCommand(string $command): string { $maxOutputBytes = self::MAX_OUTPUT_SIZE_BYTES; + $readLimit = $maxOutputBytes + 1; - return "output_file=\$(mktemp); trap 'rm -f \"\$output_file\"' EXIT; set +e; set -o pipefail; {$command} 2>&1 | { head -c {$maxOutputBytes} > \"\$output_file\"; if IFS= read -r -n 1 extra_byte; then printf '\n\n[... Output truncated at 5MB limit ...]' >> \"\$output_file\"; fi; cat > /dev/null; }; exit_code=\${PIPESTATUS[0]}; if [ \"\$exit_code\" -eq 0 ]; then cat \"\$output_file\"; else cat \"\$output_file\" >&2; fi; exit \$exit_code"; + return "output_file=\$(mktemp); trap 'rm -f \"\$output_file\"' EXIT; set +e; set -o pipefail; {$command} 2>&1 | { head -c {$readLimit} > \"\$output_file\"; cat > /dev/null; }; exit_code=\${PIPESTATUS[0]}; if [ \"\$(wc -c < \"\$output_file\")\" -gt {$maxOutputBytes} ]; then truncate -s {$maxOutputBytes} \"\$output_file\"; printf '\n\n[... Output truncated at 5MB limit ...]' >> \"\$output_file\"; fi; if [ \"\$exit_code\" -eq 0 ]; then cat \"\$output_file\"; else cat \"\$output_file\" >&2; fi; exit \$exit_code"; } /** diff --git a/app/Models/LocalFileVolume.php b/app/Models/LocalFileVolume.php index 968e6c3d04..6b0124cb97 100644 --- a/app/Models/LocalFileVolume.php +++ b/app/Models/LocalFileVolume.php @@ -113,9 +113,9 @@ class LocalFileVolume extends BaseModel return; } - $content = instant_remote_process(["cat {$escapedPath}"], $server, false); + $content = $this->readRemoteFileContent($escapedPath, $server); // Check if content contains binary data by looking for null bytes or non-printable characters - if (str_contains($content, "\0") || preg_match('/[\x00-\x08\x0B\x0C\x0E-\x1F]/', $content)) { + if ($content !== self::TOO_LARGE_PLACEHOLDER && (str_contains($content, "\0") || preg_match('/[\x00-\x08\x0B\x0C\x0E-\x1F]/', $content))) { $content = self::BINARY_PLACEHOLDER; } $this->content = $content; @@ -136,6 +136,27 @@ class LocalFileVolume extends BaseModel return $size > self::MAX_CONTENT_SIZE; } + /** + * Cap the remote read itself so a file that grows after the size check + * cannot be fully slurped into PHP memory. + */ + protected function readRemoteFileContent(string $escapedPath, $server): string + { + $readLimit = self::MAX_CONTENT_SIZE + 1; + $content = instant_remote_process(["head -c {$readLimit} {$escapedPath}"], $server, false); + + return self::contentFromBoundedRead($content); + } + + public static function contentFromBoundedRead(?string $content): string + { + if (strlen((string) $content) > self::MAX_CONTENT_SIZE) { + return self::TOO_LARGE_PLACEHOLDER; + } + + return (string) $content; + } + public function deleteStorageOnServer() { if ($this->is_host_file) { @@ -228,7 +249,7 @@ class LocalFileVolume extends BaseModel if ($this->remoteFileExceedsLimit($escapedPath, $server)) { $this->content = self::TOO_LARGE_PLACEHOLDER; } else { - $this->content = instant_remote_process(["cat {$escapedPath}"], $server, false); + $this->content = $this->readRemoteFileContent($escapedPath, $server); } $this->is_directory = false; $this->save(); diff --git a/tests/Unit/LocalFileVolumeContentSizeTest.php b/tests/Unit/LocalFileVolumeContentSizeTest.php index 1954539785..1894bf4394 100644 --- a/tests/Unit/LocalFileVolumeContentSizeTest.php +++ b/tests/Unit/LocalFileVolumeContentSizeTest.php @@ -88,3 +88,38 @@ it('marks git-based file volumes as files before refreshing their content', func '/\$fileVolume->is_directory = false;\s+\$fileVolume->save\(\);\s+if \(\$fileVolume->is_based_on_git\) \{/' ); }); + +it('bounds the remote file read itself to prevent a size-check race', function () { + $source = file_get_contents(app_path('Models/LocalFileVolume.php')); + $loadStorage = str($source) + ->after('public function loadStorageOnServer()') + ->before('public function deleteStorageOnServer()'); + + expect($loadStorage->value()) + ->toContain('head -c') + ->not->toContain('instant_remote_process(["cat {$escapedPath}"]'); +}); + +it('bounds directory-to-file conflict reads the same way', function () { + $source = file_get_contents(app_path('Models/LocalFileVolume.php')); + $saveStorage = str($source) + ->after('public function saveStorageOnServer()') + ->before('protected function plainMountPath'); + + expect($saveStorage->value()) + ->not->toContain('instant_remote_process(["cat {$escapedPath}"]'); +}); + +it('treats a bounded remote read that exceeds the limit as too large', function () { + $oversized = str_repeat('a', LocalFileVolume::MAX_CONTENT_SIZE + 1); + + expect(LocalFileVolume::contentFromBoundedRead($oversized)) + ->toBe(LocalFileVolume::TOO_LARGE_PLACEHOLDER); +}); + +it('keeps a bounded remote read that fits the limit', function () { + expect(LocalFileVolume::contentFromBoundedRead('hello')) + ->toBe('hello') + ->and(LocalFileVolume::contentFromBoundedRead(null)) + ->toBe(''); +}); diff --git a/tests/Unit/RemoteOutputSizeLimitsTest.php b/tests/Unit/RemoteOutputSizeLimitsTest.php index b8341dd73f..3660e88284 100644 --- a/tests/Unit/RemoteOutputSizeLimitsTest.php +++ b/tests/Unit/RemoteOutputSizeLimitsTest.php @@ -58,7 +58,7 @@ it('bounds scheduled task output before storing or notifying', function () { expect($source) ->toContain('MAX_OUTPUT_SIZE_BYTES') - ->toContain('head -c {$maxOutputBytes}') + ->toContain('head -c {$readLimit}') ->toContain('[... Output truncated at'); $reflection = new ReflectionClass(ScheduledTaskJob::class); @@ -70,7 +70,7 @@ it('bounds scheduled task output before storing or notifying', function () { exec('bash -c '.escapeshellarg($failureCommand).' 2>&1', $failureOutput, $failureExitCode); expect(ScheduledTaskJob::MAX_OUTPUT_SIZE_BYTES)->toBe(5 * 1024 * 1024) - ->and($command)->toContain('head -c 5242880') + ->and($command)->toContain('head -c 5242881') ->and($exitCode)->toBe(0) ->and($commandExitCode)->toBe(0) ->and(implode("\n", $commandOutput))->toBe('hello') @@ -78,6 +78,20 @@ it('bounds scheduled task output before storing or notifying', function () { ->and(implode("\n", $failureOutput))->toBe('failure'); }); +it('marks scheduled task output that exceeds the limit as truncated', function () { + $reflection = new ReflectionClass(ScheduledTaskJob::class); + $method = $reflection->getMethod('boundedTaskCommand'); + $largeOutputCommand = 'head -c '.(ScheduledTaskJob::MAX_OUTPUT_SIZE_BYTES + 1).' /dev/zero | tr "\\0" "x"'; + $command = $method->invoke($reflection->newInstanceWithoutConstructor(), $largeOutputCommand); + + exec('bash -c '.escapeshellarg($command), $output, $exitCode); + $taskOutput = implode("\n", $output); + + expect($exitCode)->toBe(0) + ->and(strlen($taskOutput))->toBeGreaterThan(ScheduledTaskJob::MAX_OUTPUT_SIZE_BYTES) + ->and($taskOutput)->toEndWith('[... Output truncated at 5MB limit ...]'); +}); + it('does not pass the scheduled task output wrapper through the sudo rewriter', function () { $source = remoteOutputSource('app/Jobs/ScheduledTaskJob.php'); $reflection = new ReflectionClass(ScheduledTaskJob::class); From 20a627ffe6f84f48643cacfb4d6a1b6ba03e0316 Mon Sep 17 00:00:00 2001 From: MarkosTech Date: Sun, 16 Aug 2026 13:14:28 +0200 Subject: [PATCH 05/42] fix(databases): redirect stdin for the SSL cert chown Remote command lists are piped into `bash -se`, so the script sits on stdin. `docker compose run` attaches stdin and eats the rest of it, including the `docker compose up -d` that follows. The container is removed and never recreated, and bash exits 0, so the deploy reports success. `-T` does not help, it does not detach stdin. Affects MongoDB, MySQL, MariaDB and PostgreSQL when enable_ssl is set. --- app/Actions/Database/StartMariadb.php | 2 +- app/Actions/Database/StartMongodb.php | 2 +- app/Actions/Database/StartMysql.php | 2 +- app/Actions/Database/StartPostgresql.php | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/app/Actions/Database/StartMariadb.php b/app/Actions/Database/StartMariadb.php index f44008085c..c4f98cfdfe 100644 --- a/app/Actions/Database/StartMariadb.php +++ b/app/Actions/Database/StartMariadb.php @@ -211,7 +211,7 @@ class StartMariadb $this->commands[] = dockerStopCommand(10, $container_name, $this->database->destination->server).' 2>/dev/null || true'; $this->commands[] = "docker rm -f $container_name 2>/dev/null || true"; if ($this->database->enable_ssl) { - $this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml run --rm --no-deps --user root --entrypoint chown $container_name mysql:mysql /etc/mysql/certs/server.key /etc/mysql/certs/server.crt"; + $this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml run --rm --no-deps --user root --entrypoint chown $container_name mysql:mysql /etc/mysql/certs/server.key /etc/mysql/certs/server.crt < /dev/null"; } $this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml up -d"; $this->commands[] = "echo 'Database started.'"; diff --git a/app/Actions/Database/StartMongodb.php b/app/Actions/Database/StartMongodb.php index b31251ce20..0f9bba6797 100644 --- a/app/Actions/Database/StartMongodb.php +++ b/app/Actions/Database/StartMongodb.php @@ -260,7 +260,7 @@ class StartMongodb $this->commands[] = dockerStopCommand(10, $container_name, $this->database->destination->server).' 2>/dev/null || true'; $this->commands[] = "docker rm -f $container_name 2>/dev/null || true"; if ($this->database->enable_ssl) { - $this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml run --rm --no-deps --user root --entrypoint chown $container_name mongodb:mongodb /etc/mongo/certs/server.pem"; + $this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml run --rm --no-deps --user root --entrypoint chown $container_name mongodb:mongodb /etc/mongo/certs/server.pem < /dev/null"; } $this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml up -d"; $this->commands[] = "echo 'Database started.'"; diff --git a/app/Actions/Database/StartMysql.php b/app/Actions/Database/StartMysql.php index 16b8e15856..3eab004643 100644 --- a/app/Actions/Database/StartMysql.php +++ b/app/Actions/Database/StartMysql.php @@ -212,7 +212,7 @@ class StartMysql $this->commands[] = dockerStopCommand(10, $container_name, $this->database->destination->server).' 2>/dev/null || true'; $this->commands[] = "docker rm -f $container_name 2>/dev/null || true"; if ($this->database->enable_ssl) { - $this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml run --rm --no-deps --user root --entrypoint chown $container_name mysql:mysql /etc/mysql/certs/server.key /etc/mysql/certs/server.crt"; + $this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml run --rm --no-deps --user root --entrypoint chown $container_name mysql:mysql /etc/mysql/certs/server.key /etc/mysql/certs/server.crt < /dev/null"; } $this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml up -d"; diff --git a/app/Actions/Database/StartPostgresql.php b/app/Actions/Database/StartPostgresql.php index 3b0f820df3..21cb278fbb 100644 --- a/app/Actions/Database/StartPostgresql.php +++ b/app/Actions/Database/StartPostgresql.php @@ -222,7 +222,7 @@ class StartPostgresql $this->commands[] = dockerStopCommand(10, $container_name, $this->database->destination->server).' 2>/dev/null || true'; $this->commands[] = "docker rm -f $container_name 2>/dev/null || true"; if ($this->database->enable_ssl) { - $this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml run --rm --no-deps --user root --entrypoint chown $container_name postgres:postgres /var/lib/postgresql/certs/server.key /var/lib/postgresql/certs/server.crt"; + $this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml run --rm --no-deps --user root --entrypoint chown $container_name postgres:postgres /var/lib/postgresql/certs/server.key /var/lib/postgresql/certs/server.crt < /dev/null"; } $this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml up -d"; $this->commands[] = "echo 'Database started.'"; From 70b9acc42467278373e00de77abb40684e25b395 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Sun, 16 Aug 2026 23:48:25 +0200 Subject: [PATCH 06/42] fix(ui): position table dropdowns outside overflowing containers Update Coolify release metadata to version 4.3.7 and nightly 4.3.8. --- config/constants.php | 2 +- other/nightly/versions.json | 4 +-- .../views/components/table/dropdown.blade.php | 31 ++++++++++++++++--- tests/Feature/StandardTableComponentsTest.php | 4 +-- tests/Feature/TablePaginationLoadingTest.php | 14 +++++++++ tests/Unit/ProductionImageWorkflowTest.php | 6 ++-- versions.json | 4 +-- 7 files changed, 51 insertions(+), 14 deletions(-) diff --git a/config/constants.php b/config/constants.php index 1e6395df8b..76e34334c2 100644 --- a/config/constants.php +++ b/config/constants.php @@ -2,7 +2,7 @@ return [ 'coolify' => [ - 'version' => env('COOLIFY_VERSION') ?: '4.3.6', + 'version' => env('COOLIFY_VERSION') ?: '4.3.7', 'helper_version' => '1.0.15', 'realtime_version' => '1.0.17', 'railpack_version' => '0.23.0', diff --git a/other/nightly/versions.json b/other/nightly/versions.json index e32d035c55..2f449eed8c 100644 --- a/other/nightly/versions.json +++ b/other/nightly/versions.json @@ -1,10 +1,10 @@ { "coolify": { "v4": { - "version": "4.3.6" + "version": "4.3.7" }, "nightly": { - "version": "4.3.7" + "version": "4.3.8" }, "helper": { "version": "1.0.15" diff --git a/resources/views/components/table/dropdown.blade.php b/resources/views/components/table/dropdown.blade.php index e236bff305..6a0472191e 100644 --- a/resources/views/components/table/dropdown.blade.php +++ b/resources/views/components/table/dropdown.blade.php @@ -4,13 +4,36 @@ 'multiselectable' => false, ]) -
-
+
+
{{ $trigger }}
-
{{ $slot }}
diff --git a/tests/Feature/StandardTableComponentsTest.php b/tests/Feature/StandardTableComponentsTest.php index 684234518f..28f07a9a2f 100644 --- a/tests/Feature/StandardTableComponentsTest.php +++ b/tests/Feature/StandardTableComponentsTest.php @@ -24,8 +24,8 @@ it('renders the standard table toolbar controls', function () { ->toContain('wire:model.live="search"') ->not->toContain('x-teleport="body"') ->not->toContain('floatingDropdown(') - ->not->toContain('position: fixed') - ->toContain('absolute top-full') + ->toContain('position: fixed') + ->toContain('getBoundingClientRect()') ->toContain('x-show="open"') ->toContain('aria-multiselectable="true"') ->toContain('Reset filters') diff --git a/tests/Feature/TablePaginationLoadingTest.php b/tests/Feature/TablePaginationLoadingTest.php index f6e5f5a81a..d26a1a33da 100644 --- a/tests/Feature/TablePaginationLoadingTest.php +++ b/tests/Feature/TablePaginationLoadingTest.php @@ -30,6 +30,20 @@ it('renders the shared page size selector', function () { ->toContain('max="100"'); }); +it('positions table dropdown panels outside overflowing containers', function () { + $html = Blade::render(<<<'BLADE' + + + + + BLADE); + + expect($html) + ->toContain('position: fixed') + ->toContain('getBoundingClientRect()') + ->toContain('x-on:scroll.window'); +}); + it('renders compact client-side pagination', function () { $html = Blade::render(''); diff --git a/tests/Unit/ProductionImageWorkflowTest.php b/tests/Unit/ProductionImageWorkflowTest.php index c7d1229d2f..754eca1c30 100644 --- a/tests/Unit/ProductionImageWorkflowTest.php +++ b/tests/Unit/ProductionImageWorkflowTest.php @@ -23,9 +23,9 @@ it('publishes v4 branch builds under the commit sha with a traceable internal ve ->toContain('ARG COOLIFY_VERSION') ->toContain('ENV COOLIFY_VERSION=${COOLIFY_VERSION}') ->and($constants) - ->toContain("'version' => env('COOLIFY_VERSION') ?: '4.3.6'") - ->and($versions['coolify']['v4']['version'])->toBe('4.3.6') - ->and($versions['coolify']['nightly']['version'])->toBe('4.3.7') + ->toContain("'version' => env('COOLIFY_VERSION') ?: '4.3.7'") + ->and($versions['coolify']['v4']['version'])->toBe('4.3.7') + ->and($versions['coolify']['nightly']['version'])->toBe('4.3.8') ->and($nightlyVersions)->toBe($versions); }); diff --git a/versions.json b/versions.json index e32d035c55..2f449eed8c 100644 --- a/versions.json +++ b/versions.json @@ -1,10 +1,10 @@ { "coolify": { "v4": { - "version": "4.3.6" + "version": "4.3.7" }, "nightly": { - "version": "4.3.7" + "version": "4.3.8" }, "helper": { "version": "1.0.15" From 5872cc095cd2de92ab92466d646e51bd1bf76440 Mon Sep 17 00:00:00 2001 From: Dion VH <91065539+dionvanhecke@users.noreply.github.com> Date: Mon, 17 Aug 2026 12:07:12 +0200 Subject: [PATCH 07/42] fix(docker): docker cleanup correctly checks the coolify.managed label (#8831) --- app/Actions/Server/CleanupDocker.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/Actions/Server/CleanupDocker.php b/app/Actions/Server/CleanupDocker.php index e065161886..04fe00ad48 100644 --- a/app/Actions/Server/CleanupDocker.php +++ b/app/Actions/Server/CleanupDocker.php @@ -131,7 +131,7 @@ class CleanupDocker $commands[] = "docker images --format '{{.Repository}}:{{.Tag}}' | ". $grepCommands.' | '. - "xargs -r -I {} sh -c 'docker inspect --format \"{{{{index .Config.Labels \\\"coolify.managed\\\"}}}}\" \"{}\" 2>/dev/null | grep -q true || docker rmi \"{}\" 2>/dev/null' || true"; + "xargs -r -I {} sh -c 'docker inspect --format \"{{index .Config.Labels \\\"coolify.managed\\\"}}\" \"{}\" 2>/dev/null | grep -q true || docker rmi \"{}\" 2>/dev/null' || true"; return implode(' && ', $commands); } From 282227b238c1958a01721cb935da09761099db0a Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Mon, 17 Aug 2026 16:05:21 +0200 Subject: [PATCH 08/42] fix(domains): preserve ports in split URL inputs (#11328) --- app/Livewire/Project/Application/Domains.php | 34 ++++++++++ app/Livewire/Project/Service/Domains.php | 35 ++++++++++ app/Livewire/Storage/Create.php | 13 ++++ app/Livewire/Storage/Form.php | 18 +++++ .../components/forms/domain-input.blade.php | 67 ++++--------------- .../views/components/forms/listbox.blade.php | 5 +- .../project/application/domains.blade.php | 31 ++------- .../application/partials/domain-row.blade.php | 7 +- .../project/service/domains.blade.php | 30 ++------- .../service/partials/domain-table.blade.php | 8 +-- .../views/livewire/storage/create.blade.php | 2 +- .../views/livewire/storage/form.blade.php | 2 +- tests/Feature/ApplicationDomainsTest.php | 24 +++++-- .../Feature/ListboxTriggerTruncationTest.php | 9 +++ tests/Feature/ServiceDomainsTest.php | 4 +- tests/Feature/SplitUrlInputTest.php | 50 +++++--------- .../S3StorageEndpointNormalizationTest.php | 4 +- 17 files changed, 182 insertions(+), 161 deletions(-) diff --git a/app/Livewire/Project/Application/Domains.php b/app/Livewire/Project/Application/Domains.php index 9ddd5e740e..477abc08e6 100644 --- a/app/Livewire/Project/Application/Domains.php +++ b/app/Livewire/Project/Application/Domains.php @@ -6,6 +6,7 @@ use App\Livewire\Concerns\InteractsWithCloudflareDomainConnect; use App\Livewire\Project\Shared\ConfigurationChecker; use App\Models\Application; use App\Models\Server; +use App\Support\DomainUrlParts; use App\Support\ValidationPatterns; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Support\Collection; @@ -35,12 +36,20 @@ class Domains extends Component public string $newDomain = ''; + public array $newDomainParts = ['scheme' => 'https', 'host' => '', 'port' => '', 'path' => '']; + + public bool $newDomainPartsChanged = false; + public ?string $newDomainService = null; public ?int $editingIndex = null; public string $editingDomain = ''; + public array $editingDomainParts = ['scheme' => 'https', 'host' => '', 'port' => '', 'path' => '']; + + public bool $editingDomainPartsChanged = false; + public ?string $editingService = null; /** @var array */ @@ -662,6 +671,12 @@ class Domains extends Component $this->resetAddDomainDnsGate(); } + public function updatedNewDomainParts(): void + { + $this->newDomainPartsChanged = true; + $this->resetAddDomainDnsGate(); + } + public function updatedNewDomainService(): void { $this->resetAddDomainDnsGate(); @@ -677,6 +692,8 @@ class Domains extends Component public function resetAddDomainForm(): void { $this->newDomain = ''; + $this->newDomainParts = DomainUrlParts::empty(); + $this->newDomainPartsChanged = false; $this->resetAddDomainDnsGate(); $this->resetErrorBag('newDomain'); } @@ -743,6 +760,9 @@ class Domains extends Component return; } + if ($this->newDomainPartsChanged) { + $this->newDomain = DomainUrlParts::compose(...$this->newDomainParts); + } $this->validateOnly('newDomain'); $normalized = ValidationPatterns::normalizeApplicationDomains($this->newDomain); @@ -893,6 +913,12 @@ class Domains extends Component $this->resetEditDomainDnsGate(); } + public function updatedEditingDomainParts(): void + { + $this->editingDomainPartsChanged = true; + $this->resetEditDomainDnsGate(); + } + public function resetEditDomainDnsGate(): void { $this->editDomainDnsFailed = false; @@ -908,10 +934,13 @@ class Domains extends Component $this->editingIndex = $index; $this->editingDomain = $this->domainRows[$index]['url']; + $this->editingDomainParts = DomainUrlParts::split($this->editingDomain); + $this->editingDomainPartsChanged = false; $this->editingService = $this->domainRows[$index]['service']; $this->resetEditDomainDnsGate(); $this->resetErrorBag('editingDomain'); $this->showEditDomainModal = true; + $this->dispatch('open-edit-domain'); } public function addSuggestedDomain(int $index): void @@ -990,6 +1019,8 @@ class Domains extends Component $this->showEditDomainModal = false; $this->editingIndex = null; $this->editingDomain = ''; + $this->editingDomainParts = DomainUrlParts::empty(); + $this->editingDomainPartsChanged = false; $this->editingService = null; $this->resetEditDomainDnsGate(); $this->resetErrorBag('editingDomain'); @@ -1021,6 +1052,9 @@ class Domains extends Component return; } + if ($this->editingDomainPartsChanged) { + $this->editingDomain = DomainUrlParts::compose(...$this->editingDomainParts); + } $this->validateOnly('editingDomain'); $normalized = ValidationPatterns::normalizeApplicationDomains($this->editingDomain); diff --git a/app/Livewire/Project/Service/Domains.php b/app/Livewire/Project/Service/Domains.php index a5479ca069..ad31560b5a 100644 --- a/app/Livewire/Project/Service/Domains.php +++ b/app/Livewire/Project/Service/Domains.php @@ -43,10 +43,18 @@ class Domains extends Component public string $newDomain = ''; + public array $newDomainParts = ['scheme' => 'https', 'host' => '', 'port' => '', 'path' => '']; + + public bool $newDomainPartsChanged = false; + public ?int $editingIndex = null; public string $editingDomain = ''; + public array $editingDomainParts = ['scheme' => 'https', 'host' => '', 'port' => '', 'path' => '']; + + public bool $editingDomainPartsChanged = false; + public ?int $editingServiceApplicationId = null; public bool $showEditDomainModal = false; @@ -515,6 +523,12 @@ class Domains extends Component $this->forceSaveDns = false; } + public function updatedNewDomainParts(): void + { + $this->newDomainPartsChanged = true; + $this->resetAddDomainDnsGate(); + } + public function updatedEditingDomain(): void { $this->editDomainDnsFailed = false; @@ -522,6 +536,12 @@ class Domains extends Component $this->forceSaveEditDns = false; } + public function updatedEditingDomainParts(): void + { + $this->editingDomainPartsChanged = true; + $this->updatedEditingDomain(); + } + public function confirmAddDomainDespiteDns(): void { $this->forceSaveDns = true; @@ -842,6 +862,9 @@ class Domains extends Component { try { $this->authorize('update', $this->service); + if ($this->newDomainPartsChanged) { + $this->newDomain = DomainUrlParts::compose(...$this->newDomainParts); + } $this->validateOnly('newDomain'); $app = $this->findServiceApp($this->newServiceApplicationId); @@ -893,6 +916,8 @@ class Domains extends Component } $this->newDomain = ''; + $this->newDomainParts = DomainUrlParts::empty(); + $this->newDomainPartsChanged = false; $this->addDomainDnsFailed = false; $this->addDomainDnsMessage = ''; $this->forceSaveDns = false; @@ -916,12 +941,15 @@ class Domains extends Component $this->editingIndex = $index; $this->editingDomain = $this->domainRows[$index]['url']; + $this->editingDomainParts = DomainUrlParts::split($this->editingDomain); + $this->editingDomainPartsChanged = false; $this->editingServiceApplicationId = (int) $this->domainRows[$index]['service_application_id']; $this->editDomainDnsFailed = false; $this->editDomainDnsMessage = ''; $this->forceSaveEditDns = false; $this->resetErrorBag('editingDomain'); $this->showEditDomainModal = true; + $this->dispatch('open-edit-domain'); } public function cancelEdit(): void @@ -929,6 +957,8 @@ class Domains extends Component $this->showEditDomainModal = false; $this->editingIndex = null; $this->editingDomain = ''; + $this->editingDomainParts = DomainUrlParts::empty(); + $this->editingDomainPartsChanged = false; $this->editingServiceApplicationId = null; $this->editDomainDnsFailed = false; $this->editDomainDnsMessage = ''; @@ -945,6 +975,9 @@ class Domains extends Component return; } + if ($this->editingDomainPartsChanged) { + $this->editingDomain = DomainUrlParts::compose(...$this->editingDomainParts); + } $this->validateOnly('editingDomain'); $app = $this->findServiceApp($this->editingServiceApplicationId); @@ -1130,6 +1163,8 @@ class Domains extends Component } $this->newDomain = $domain; + $this->newDomainParts = DomainUrlParts::split($domain); + $this->newDomainPartsChanged = true; $this->updatedNewDomain(); } catch (\Throwable $e) { handleError($e, $this); diff --git a/app/Livewire/Storage/Create.php b/app/Livewire/Storage/Create.php index d741e69180..9e22e5491e 100644 --- a/app/Livewire/Storage/Create.php +++ b/app/Livewire/Storage/Create.php @@ -5,6 +5,7 @@ namespace App\Livewire\Storage; use App\Models\S3Storage; use App\Rules\SafeWebhookUrl; use App\Rules\ValidS3BucketName; +use App\Support\DomainUrlParts; use App\Support\ValidationPatterns; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Support\Uri; @@ -28,6 +29,10 @@ class Create extends Component public string $endpoint = ''; + public array $endpointParts = ['scheme' => 'https', 'host' => '', 'port' => '', 'path' => '']; + + public bool $endpointPartsChanged = false; + public S3Storage $storage; protected function rules(): array @@ -76,6 +81,9 @@ class Create extends Component try { $this->authorize('create', S3Storage::class); + if ($this->endpointPartsChanged) { + $this->endpoint = DomainUrlParts::compose(...$this->endpointParts); + } $this->endpoint = $this->normalizeEndpoint($this->endpoint); $this->validate(); $this->storage = new S3Storage; @@ -101,6 +109,11 @@ class Create extends Component } } + public function updatedEndpointParts(): void + { + $this->endpointPartsChanged = true; + } + private function connectionErrorDescription(\Throwable $exception): string { $settingsUrl = route('settings.advanced').'#endpoint-section'; diff --git a/app/Livewire/Storage/Form.php b/app/Livewire/Storage/Form.php index 94a0657efc..30084dcd39 100644 --- a/app/Livewire/Storage/Form.php +++ b/app/Livewire/Storage/Form.php @@ -5,6 +5,7 @@ namespace App\Livewire\Storage; use App\Models\S3Storage; use App\Rules\SafeWebhookUrl; use App\Rules\ValidS3BucketName; +use App\Support\DomainUrlParts; use App\Support\ValidationPatterns; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Support\Facades\DB; @@ -24,6 +25,10 @@ class Form extends Component public string $endpoint; + public array $endpointParts = ['scheme' => 'https', 'host' => '', 'port' => '', 'path' => '']; + + public bool $endpointPartsChanged = false; + public string $bucket; public string $region; @@ -101,6 +106,8 @@ class Form extends Component $this->name = $this->storage->name; $this->description = $this->storage->description; $this->endpoint = $this->storage->endpoint; + $this->endpointParts = DomainUrlParts::split($this->endpoint); + $this->endpointPartsChanged = false; $this->bucket = $this->storage->bucket; $this->region = $this->storage->region; $this->key = $this->storage->key; @@ -126,6 +133,9 @@ class Form extends Component try { $this->authorize('validateConnection', $this->storage); + if ($this->endpointPartsChanged) { + $this->endpoint = DomainUrlParts::compose(...$this->endpointParts); + } $testedStorage = new S3Storage; $testedStorage->uuid = $this->storage->uuid; $testedStorage->team_id = $this->storage->team_id; @@ -166,6 +176,9 @@ class Form extends Component { try { $this->authorize('update', $this->storage); + if ($this->endpointPartsChanged) { + $this->endpoint = DomainUrlParts::compose(...$this->endpointParts); + } DB::transaction(function () { $this->validate(); @@ -195,4 +208,9 @@ class Form extends Component return handleError($e, $this); } } + + public function updatedEndpointParts(): void + { + $this->endpointPartsChanged = true; + } } diff --git a/resources/views/components/forms/domain-input.blade.php b/resources/views/components/forms/domain-input.blade.php index d6e6bce856..5a4b5f950e 100644 --- a/resources/views/components/forms/domain-input.blade.php +++ b/resources/views/components/forms/domain-input.blade.php @@ -1,68 +1,27 @@ @props([ 'id', - 'wire' => true, - 'value' => '', 'errorId' => null, 'hostLabel' => 'Domain', 'hostPlaceholder' => 'app.example.com', ]) -
whereStartsWith('x-model') }}> +
- +
-
- - @error($errorId ?? $id) + + @error($errorId ?? "{$id}.host") @php preg_match('/(https?:\/\/\S+)$/', $message, $validationLinkMatches); $validationLink = $validationLinkMatches[1] ?? null; @@ -82,16 +41,16 @@
- +
- +

Optional path, query, or fragment appended after the domain and port.

diff --git a/resources/views/components/forms/listbox.blade.php b/resources/views/components/forms/listbox.blade.php index bfcb4437e7..4677911d94 100644 --- a/resources/views/components/forms/listbox.blade.php +++ b/resources/views/components/forms/listbox.blade.php @@ -90,6 +90,9 @@ const gap = 4; const edge = 12; const triggerRect = trigger.getBoundingClientRect(); + panel.style.width = 'max-content'; + panel.style.minWidth = `${triggerRect.width}px`; + panel.style.maxWidth = `${window.innerWidth - (edge * 2)}px`; const panelWidth = Math.min( Math.max(triggerRect.width, panel.offsetWidth), window.innerWidth - (edge * 2), @@ -107,8 +110,6 @@ panel.style.top = `${top}px`; panel.style.left = `${left}px`; panel.style.width = `${panelWidth}px`; - panel.style.maxWidth = `${window.innerWidth - (edge * 2)}px`; - panel.style.minWidth = `${triggerRect.width}px`; this.positioned = true; }, }" x-modelable="value" :class="{ 'pointer-events-none opacity-70': saving }" diff --git a/resources/views/livewire/project/application/domains.blade.php b/resources/views/livewire/project/application/domains.blade.php index fb5bf9b1df..04dd249c7e 100644 --- a/resources/views/livewire/project/application/domains.blade.php +++ b/resources/views/livewire/project/application/domains.blade.php @@ -15,30 +15,14 @@ domainSearch: '', modalOpen: @js($showEditDomainModal || $editDomainDnsFailed), editingServiceLabel: @js($editingService ?? ''), - localEditingIndex: @js($editingIndex), - localEditingDomain: @js($editingDomain), - localEditingService: @js($editingService), - openEditDomain(index, url, service) { - this.localEditingIndex = index; - this.localEditingDomain = url; - this.localEditingService = service; - this.editingServiceLabel = service || ''; + openEditDomain() { + this.editingServiceLabel = $wire.editingService || ''; this.modalOpen = true; this.$nextTick(() => document.getElementById('editingDomainLocal')?.focus?.()); }, closeEditDomain() { this.modalOpen = false; this.editingServiceLabel = ''; - this.localEditingIndex = null; - this.localEditingDomain = ''; - this.localEditingService = null; - }, - prepareEditSubmit() { - // Sync Alpine → Livewire only when the user actually saves (one request). - $wire.editingIndex = this.localEditingIndex; - $wire.editingDomain = this.localEditingDomain; - $wire.editingService = this.localEditingService; - $wire.showEditDomainModal = true; }, matchesDomainSearch(value) { return !this.domainSearch.trim() || value.toLowerCase().includes(this.domainSearch.trim().toLowerCase()); @@ -47,7 +31,7 @@ return values.some((value) => this.matchesDomainSearch(value)); }, }" - @open-edit-domain.window="openEditDomain($event.detail.index, $event.detail.url, $event.detail.service)" + @open-edit-domain.window="openEditDomain()" @edit-domain-saved.window="closeEditDomain()"> @can('update', $application) @@ -128,7 +112,7 @@ :disabled="! auth()->user()->can('update', $application)" /> @endif - + @if ($addDomainDnsFailed) @@ -320,7 +304,7 @@
-
+
@@ -328,8 +312,7 @@
- + @if ($editDomainDnsFailed) @@ -345,7 +328,7 @@
@if ($editDomainDnsFailed) + wire:click="confirmUpdateDomainDespiteDns"> Continue @else diff --git a/resources/views/livewire/project/application/partials/domain-row.blade.php b/resources/views/livewire/project/application/partials/domain-row.blade.php index d5baaee56e..10aa21200a 100644 --- a/resources/views/livewire/project/application/partials/domain-row.blade.php +++ b/resources/views/livewire/project/application/partials/domain-row.blade.php @@ -178,12 +178,7 @@ @endif @else - diff --git a/resources/views/livewire/storage/create.blade.php b/resources/views/livewire/storage/create.blade.php index 1154a7e90b..c0d6a6e273 100644 --- a/resources/views/livewire/storage/create.blade.php +++ b/resources/views/livewire/storage/create.blade.php @@ -7,7 +7,7 @@
-
diff --git a/resources/views/livewire/storage/form.blade.php b/resources/views/livewire/storage/form.blade.php index 2a76e7cc61..d13d632fbe 100644 --- a/resources/views/livewire/storage/form.blade.php +++ b/resources/views/livewire/storage/form.blade.php @@ -17,7 +17,7 @@
@can('update', $storage) - @else diff --git a/tests/Feature/ApplicationDomainsTest.php b/tests/Feature/ApplicationDomainsTest.php index 288952a7d4..fbe72230d1 100644 --- a/tests/Feature/ApplicationDomainsTest.php +++ b/tests/Feature/ApplicationDomainsTest.php @@ -236,6 +236,22 @@ it('adds a domain to the application', function () { ->toBe(['https://app.example.com', 'https://www.app.example.com']); }); +it('composes the complete port on the server without duplicating an existing www domain', function () { + $this->application->update(['fqdn' => 'https://www.example.com:3000']); + + Livewire::test(Domains::class, ['application' => $this->application->fresh()]) + ->set('newDomainParts.host', 'example.com') + ->set('newDomainParts.port', '3000') + ->call('addDomain') + ->assertHasNoErrors() + ->assertDispatched('success'); + + expect(explode(',', (string) $this->application->fresh()->fqdn))->toBe([ + 'https://www.example.com:3000', + 'https://example.com:3000', + ]); +}); + it('adds multiple domains without replacing existing ones', function () { $this->application->update([ 'fqdn' => 'https://app.example.com', @@ -1222,16 +1238,16 @@ it('uses segmented fields when adding and editing application domains', function $component = file_get_contents(resource_path('views/components/forms/domain-input.blade.php')); expect($view) - ->toContain('toContain('toContain('toContain('not->toContain('placeholder="https://app.example.com"') ->and($component) ->toContain('Protocol') ->toContain('Domain') ->toContain('Port') ->toContain('Path') - ->toContain("scheme: 'https'") - ->toContain('toContain('wire:model="{{ $id }}.host"') + ->toContain('not->toContain(' - Copied -
+ @endif diff --git a/scripts/install.sh b/scripts/install.sh index 1773f37f1f..226b1cc5e0 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -229,6 +229,7 @@ if [ "$WARNING_SPACE" = true ]; then fi mkdir -p /data/coolify/{source,ssh,applications,databases,backups,services,proxy,sentinel} +mkdir -p /data/coolify/images mkdir -p /data/coolify/ssh/{keys,mux} mkdir -p /data/coolify/proxy/dynamic diff --git a/scripts/upgrade.sh b/scripts/upgrade.sh index a909d2e49a..516a9d7ebc 100644 --- a/scripts/upgrade.sh +++ b/scripts/upgrade.sh @@ -170,6 +170,10 @@ else log "Network 'coolify' already exists" fi +mkdir -p /data/coolify/images/{avatars,project-icons} +chown -R 9999:root /data/coolify/images +chmod -R 700 /data/coolify/images + # Fix SSH directory ownership if not owned by container user UID 9999 (fixes #6621) # Only changes owner — preserves existing group to respect custom setups SSH_OWNER=$(stat -c '%u' /data/coolify/ssh 2>/dev/null || echo "unknown") diff --git a/tests/Feature/PersistentImageStorageTest.php b/tests/Feature/PersistentImageStorageTest.php new file mode 100644 index 0000000000..59d88e3f0b --- /dev/null +++ b/tests/Feature/PersistentImageStorageTest.php @@ -0,0 +1,31 @@ +toMatchArray([ + 'driver' => 'local', + 'root' => storage_path('app/images'), + 'visibility' => 'private', + ]); +}); + +it('persists images in stable and nightly production compose files', function (string $composeFile) { + expect(file_get_contents(base_path($composeFile))) + ->toContain('/data/coolify/images:/var/www/html/storage/app/images'); +})->with([ + 'stable' => 'docker-compose.prod.yml', + 'nightly' => 'other/nightly/docker-compose.prod.yml', +]); + +it('persists images in the Windows compose file', function () { + expect(file_get_contents(base_path('docker-compose.windows.yml'))) + ->toContain('./images:/var/www/html/storage/app/images'); +}); + +it('creates the persistent image directory during installation and upgrades', function (string $script) { + expect(file_get_contents(base_path($script))) + ->toContain('/data/coolify/images'); +})->with([ + 'install' => 'scripts/install.sh', + 'upgrade' => 'scripts/upgrade.sh', +]); diff --git a/tests/Feature/ProfileAvatarTest.php b/tests/Feature/ProfileAvatarTest.php index e5abb2ef5c..cf313882f4 100644 --- a/tests/Feature/ProfileAvatarTest.php +++ b/tests/Feature/ProfileAvatarTest.php @@ -19,7 +19,7 @@ beforeEach(function () { }); it('compresses and stores an uploaded profile picture on the configured local storage', function () { - Storage::fake('local'); + Storage::fake('images'); $user = User::factory()->create(['name' => 'Test User']); $this->actingAs($user); @@ -35,18 +35,18 @@ it('compresses and stores an uploaded profile picture on the configured local st ->and($user->avatar_storage_type)->toBe('local') ->and($user->avatar_s3_storage_id)->toBeNull(); - Storage::disk('local')->assertExists($user->avatar_path); + Storage::disk('images')->assertExists($user->avatar_path); - $image = getimagesizefromstring(Storage::disk('local')->get($user->avatar_path)); + $image = getimagesizefromstring(Storage::disk('images')->get($user->avatar_path)); expect($image[0])->toBeLessThanOrEqual(256) ->and($image[1])->toBeLessThanOrEqual(256) ->and($image['mime'])->toBe('image/jpeg') - ->and(Storage::disk('local')->size($user->avatar_path))->toBeLessThan(100_000); + ->and(Storage::disk('images')->size($user->avatar_path))->toBeLessThan(100_000); }); it('stores an already compressed browser JPEG without server image extensions', function () { - Storage::fake('local'); + Storage::fake('images'); $user = User::factory()->create(['name' => 'Test User']); $contents = base64_decode('/9j/4AAQSkZJRgABAQEAYABgAAD//gA7Q1JFQVRPUjogZ2QtanBlZyB2MS4wICh1c2luZyBJSkcgSlBFRyB2NjIpLCBxdWFsaXR5ID0gODAK/9sAQwAGBAUGBQQGBgUGBwcGCAoQCgoJCQoUDg8MEBcUGBgXFBYWGh0lHxobIxwWFiAsICMmJykqKRkfLTAtKDAlKCko/9sAQwEHBwcKCAoTCgoTKBoWGigoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgo/8AAEQgAAgACAwEiAAIRAQMRAf/EAB8AAAEFAQEBAQEBAAAAAAAAAAABAgMEBQYHCAkKC//EALUQAAIBAwMCBAMFBQQEAAABfQECAwAEEQUSITFBBhNRYQcicRQygZGhCCNCscEVUtHwJDNicoIJChYXGBkaJSYnKCkqNDU2Nzg5OkNERUZHSElKU1RVVldYWVpjZGVmZ2hpanN0dXZ3eHl6g4SFhoeIiYqSk5SVlpeYmZqio6Slpqeoqaqys7S1tre4ubrCw8TFxsfIycrS09TV1tfY2drh4uPk5ebn6Onq8fLz9PX29/j5+v/EAB8BAAMBAQEBAQEBAQEAAAAAAAABAgMEBQYHCAkKC//EALURAAIBAgQEAwQHBQQEAAECdwABAgMRBAUhMQYSQVEHYXETIjKBCBRCkaGxwQkjM1LwFWJy0QoWJDThJfEXGBkaJicoKSo1Njc4OTpDREVGR0hJSlNUVVZXWFlaY2RlZmdoaWpzdHV2d3h5eoKDhIWGh4iJipKTlJWWl5iZmqKjpKWmp6ipqrKztLW2t7i5usLDxMXGx8jJytLT1NXW19jZ2uLj5OXm5+jp6vLz9PX29/j5+v/aAAwDAQACEQMRAD8A+qaKKKAP/9k='); $path = tempnam(sys_get_temp_dir(), 'avatar'); @@ -55,17 +55,17 @@ it('stores an already compressed browser JPEG without server image extensions', app(AvatarStorageService::class)->store($user, $upload); - expect(Storage::disk('local')->get("avatars/{$user->id}/avatar.jpg"))->toBe($contents); + expect(Storage::disk('images')->get("avatars/{$user->id}/avatar.jpg"))->toBe($contents); }); it('serves the authenticated users profile picture', function () { - Storage::fake('local'); + Storage::fake('images'); $user = User::factory()->create([ 'name' => 'Test User', 'avatar_path' => 'avatars/1/avatar.jpg', 'avatar_storage_type' => 'local', ]); - Storage::disk('local')->put($user->avatar_path, 'avatar-content'); + Storage::disk('images')->put($user->avatar_path, 'avatar-content'); $this->withoutMiddleware()->actingAs($user) ->get(route('profile.avatar')) @@ -74,13 +74,13 @@ it('serves the authenticated users profile picture', function () { }); it('removes the current profile picture', function () { - Storage::fake('local'); + Storage::fake('images'); $user = User::factory()->create([ 'name' => 'Test User', 'avatar_path' => 'avatars/1/avatar.jpg', 'avatar_storage_type' => 'local', ]); - Storage::disk('local')->put($user->avatar_path, 'avatar-content'); + Storage::disk('images')->put($user->avatar_path, 'avatar-content'); $this->actingAs($user); Livewire::test(Index::class) @@ -88,7 +88,7 @@ it('removes the current profile picture', function () { ->assertHasNoErrors(); expect($user->refresh()->avatar_path)->toBeNull(); - Storage::disk('local')->assertMissing('avatars/1/avatar.jpg'); + Storage::disk('images')->assertMissing('avatars/1/avatar.jpg'); }); it('falls back cleanly when the avatars S3 storage no longer exists', function () { diff --git a/tests/Feature/ProjectIconTest.php b/tests/Feature/ProjectIconTest.php index 3599c0b4b5..b5b6ed0887 100644 --- a/tests/Feature/ProjectIconTest.php +++ b/tests/Feature/ProjectIconTest.php @@ -31,7 +31,7 @@ beforeEach(function () { }); it('stores a project icon using the instance image storage setting', function () { - Storage::fake('local'); + Storage::fake('images'); $upload = UploadedFile::fake()->createWithContent('project.jpg', file_get_contents(base_path('tests/Fixtures/project-icon.jpg'))); @@ -47,16 +47,16 @@ it('stores a project icon using the instance image storage setting', function () ->and($this->project->icon_storage_type)->toBe('local') ->and($this->project->icon_s3_storage_id)->toBeNull(); - Storage::disk('local')->assertExists($this->project->icon_path); + Storage::disk('images')->assertExists($this->project->icon_path); }); it('serves a project icon only to a member of its team', function () { - Storage::fake('local'); + Storage::fake('images'); $this->project->forceFill([ 'icon_path' => "project-icons/{$this->project->uuid}/icon.jpg", 'icon_storage_type' => 'local', ])->save(); - Storage::disk('local')->put($this->project->icon_path, 'icon-content'); + Storage::disk('images')->put($this->project->icon_path, 'icon-content'); $this->withoutMiddleware()->get(route('project.icon', ['project_uuid' => $this->project->uuid])) ->assertSuccessful() @@ -73,20 +73,20 @@ it('serves a project icon only to a member of its team', function () { }); it('removes a project icon', function () { - Storage::fake('local'); + Storage::fake('images'); $path = "project-icons/{$this->project->uuid}/icon.jpg"; $this->project->forceFill([ 'icon_path' => $path, 'icon_storage_type' => 'local', ])->save(); - Storage::disk('local')->put($path, 'icon-content'); + Storage::disk('images')->put($path, 'icon-content'); Livewire::test(Edit::class, ['project_uuid' => $this->project->uuid]) ->call('removeIcon') ->assertHasNoErrors(); expect($this->project->refresh()->icon_path)->toBeNull(); - Storage::disk('local')->assertMissing($path); + Storage::disk('images')->assertMissing($path); }); it('exposes the icon URL on the projects index', function () { diff --git a/tests/Feature/ResourceDetailsVisibilityTest.php b/tests/Feature/ResourceDetailsVisibilityTest.php index 6466a33ed7..29f611cbaa 100644 --- a/tests/Feature/ResourceDetailsVisibilityTest.php +++ b/tests/Feature/ResourceDetailsVisibilityTest.php @@ -38,15 +38,22 @@ it('renders copy fields as visible readonly controls with an accessible copy act expect($html) ->toContain('label class="flex gap-1 items-center mb-1 text-sm font-medium text-black dark:text-white"') ->toContain('readonly') - ->toContain("canCopy: window.isSecureContext && typeof navigator.clipboard?.writeText === 'function'") - ->toContain("x-bind:class=\"{ 'input-with-copy-button': canCopy }\"") - ->toContain('x-show="canCopy"') + ->toContain('window.copyToClipboard') + ->toContain('input-with-copy-button') ->toContain('copy-button') ->toContain('aria-label="Copy to clipboard"') ->toContain('title="Copy to clipboard"') ->toContain('class="size-[18px] text-green-500"'); }); +it('uses the shared copy field for newly issued api tokens', function () { + $blade = file_get_contents(resource_path('views/livewire/security/api-tokens.blade.php')); + + expect($blade) + ->toContain('') + ->not->toContain('navigator.clipboard.writeText(@js(session(\'token\')))'); +}); + it('keeps copy button padding above settings-workspace input overrides', function () { $css = file_get_contents(resource_path('css/app.css')); From c314319a7eff0fcd39e3ed0b75e8cd2c19e68e77 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Mon, 17 Aug 2026 16:23:30 +0200 Subject: [PATCH 10/42] fix(sudo): preserve substitutions in backup shell commands (#11329) --- AGENTS.md | 1 + bootstrap/helpers/sudo.php | 1 + tests/Unit/ParseCommandsByLineForSudoTest.php | 18 ++++++++++++++++++ 3 files changed, 20 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 86fc0f00b0..5563a18ec1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -179,6 +179,7 @@ Coolify seeds **instance-owned** rows at primary key `0`. That value is a sentin - Run `vendor/bin/pint --dirty --format agent` before finalizing changes - Every change must have tests — write or update tests, then run them. For bug fixes, follow TDD: write a failing test first, then fix the bug (see Test Enforcement below) - Check sibling files for conventions before creating new files +- When adding remote shell commands, account for servers using non-root SSH users: commands pass through `parseCommandsByLineForSudo()`, so test pipelines, redirects, substitutions, and `sh -c`/`bash -c` scripts with the non-root sudo parser. ## Git Workflow diff --git a/bootstrap/helpers/sudo.php b/bootstrap/helpers/sudo.php index b8ef846877..397efc387c 100644 --- a/bootstrap/helpers/sudo.php +++ b/bootstrap/helpers/sudo.php @@ -95,6 +95,7 @@ function parseCommandsByLineForSudo(Collection $commands, Server $server): array $isComplexPipeCommand = ( $line->contains(' | sh') || $line->contains(' | bash') || + $line->contains(' sh -c ') || ($line->contains(' | ') && ($line->contains('||') || $line->contains('&&'))) ); diff --git a/tests/Unit/ParseCommandsByLineForSudoTest.php b/tests/Unit/ParseCommandsByLineForSudoTest.php index f294de35fa..b741b875f2 100644 --- a/tests/Unit/ParseCommandsByLineForSudoTest.php +++ b/tests/Unit/ParseCommandsByLineForSudoTest.php @@ -24,6 +24,24 @@ test('wraps complex Docker install command with pipes in bash -c', function () { expect($result[0])->toBe("sudo bash -c 'curl https://releases.rancher.com/install-docker/27.3.sh | sh || curl https://get.docker.com | sh'"); }); +test('preserves command substitutions inside database and volume backup scripts', function () { + $script = 'compressor=$(if command -v pigz; then printf pigz; else printf gzip; fi); exec $compressor'; + $command = 'docker exec database pg_dumpall | docker run --rm -i helper sh -c '.escapeshellarg($script); + $volumeCommand = 'docker run --rm helper sh -c '.escapeshellarg($script).' > /data/coolify/backups/volume.tar.gz'; + + $result = parseCommandsByLineForSudo(collect([$command, $volumeCommand]), $this->server); + + expect($result[0]) + ->toStartWith("sudo bash -c '") + ->toContain('compressor=$(if command -v pigz; then') + ->not->toContain('$(sudo if') + ->not->toContain('| sudo docker run') + ->and($result[1]) + ->toStartWith("sudo bash -c '") + ->toContain('compressor=$(if command -v pigz; then') + ->not->toContain('$(sudo if'); +}); + test('wraps complex Docker install command with multiple fallbacks', function () { $commands = collect([ 'curl --max-time 300 https://releases.rancher.com/install-docker/27.3.sh | sh || curl https://get.docker.com | sh -s -- --version 27.3', From 5152698757c5aa4fe5c731f58003fcfd24075aac Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Mon, 17 Aug 2026 16:27:00 +0200 Subject: [PATCH 11/42] fix(deployments): advance queue after cancellations (#11330) --- .../Application/CleanupPreviewDeployment.php | 10 +- app/Http/Controllers/Api/DeployController.php | 68 ++++++++---- app/Jobs/DeleteResourceJob.php | 11 ++ app/Mcp/Tools/CancelDeployment.php | 7 ++ .../Api/DeploymentCancellationApiTest.php | 102 +++++++++++++++++- ...ApplicationPreviewQueueAdvancementTest.php | 94 ++++++++++++++++ tests/Feature/Mcp/McpReadToolsTest.php | 23 ++++ 7 files changed, 288 insertions(+), 27 deletions(-) create mode 100644 tests/Feature/ApplicationPreviewQueueAdvancementTest.php diff --git a/app/Actions/Application/CleanupPreviewDeployment.php b/app/Actions/Application/CleanupPreviewDeployment.php index 74e2ff615f..803eef3983 100644 --- a/app/Actions/Application/CleanupPreviewDeployment.php +++ b/app/Actions/Application/CleanupPreviewDeployment.php @@ -54,6 +54,14 @@ class CleanupPreviewDeployment $server ); + if ($result['cancelled_deployments'] > 0) { + try { + next_after_cancel($server); + } catch (\Throwable $e) { + \Log::warning("Failed to advance deployment queue after cleaning up preview for application {$application->id}: {$e->getMessage()}"); + } + } + // Step 2: Stop and remove all running PR containers $result['killed_containers'] = $this->stopRunningContainers( $application, @@ -98,13 +106,13 @@ class CleanupPreviewDeployment $deployment->update([ 'status' => ApplicationDeploymentStatus::CANCELLED_BY_USER->value, ]); + $cancelled++; // Add cancellation log entry $deployment->addLogEntry('Deployment cancelled: Pull request closed.', 'stderr'); // Try to kill helper container if it exists $this->killHelperContainer($deployment->deployment_uuid, $server); - $cancelled++; } catch (\Throwable $e) { \Log::warning("Failed to cancel deployment {$deployment->id}: {$e->getMessage()}"); } diff --git a/app/Http/Controllers/Api/DeployController.php b/app/Http/Controllers/Api/DeployController.php index 396844cb02..a0f0cc1aed 100644 --- a/app/Http/Controllers/Api/DeployController.php +++ b/app/Http/Controllers/Api/DeployController.php @@ -238,57 +238,71 @@ class DeployController extends Controller ApplicationDeploymentStatus::IN_PROGRESS->value, ]; - if (! in_array($deployment->status, $cancellableStatuses)) { + if (! in_array($deployment->status, $cancellableStatuses, true)) { return response()->json([ 'message' => "Deployment cannot be cancelled. Current status: {$deployment->status}", ], 400); } // Perform the cancellation + $cancelled = false; + $deploymentServer = Server::whereTeamId($teamId)->find($deployment->server_id); + try { $deployment_uuid = $deployment->deployment_uuid; $kill_command = "docker rm -f {$deployment_uuid}"; $build_server_id = $deployment->build_server_id ?? $deployment->server_id; // Mark deployment as cancelled - $deployment->update([ - 'status' => ApplicationDeploymentStatus::CANCELLED_BY_USER->value, - ]); + $updated = ApplicationDeploymentQueue::whereKey($deployment->getKey()) + ->whereIn('status', $cancellableStatuses) + ->update(['status' => ApplicationDeploymentStatus::CANCELLED_BY_USER->value]); + + if ($updated !== 1) { + $deployment->refresh(); + + return response()->json([ + 'message' => "Deployment cannot be cancelled. Current status: {$deployment->status}", + ], 400); + } + + $deployment->status = ApplicationDeploymentStatus::CANCELLED_BY_USER->value; + $cancelled = true; // Get the server $server = Server::whereTeamId($teamId)->find($build_server_id); - if ($server) { - // Add cancellation log entry - $deployment->addLogEntry('Deployment cancelled by user via API.', 'stderr'); + try { + if ($server) { + // Add cancellation log entry + $deployment->addLogEntry('Deployment cancelled by user via API.', 'stderr'); - // Check if container exists and kill it - $checkCommand = "docker ps -a --filter name={$deployment_uuid} --format '{{.Names}}'"; - $containerExists = instant_remote_process([$checkCommand], $server); + // Check if container exists and kill it + $checkCommand = "docker ps -a --filter name={$deployment_uuid} --format '{{.Names}}'"; + $containerExists = instant_remote_process([$checkCommand], $server); - if ($containerExists && str($containerExists)->trim()->isNotEmpty()) { - instant_remote_process([$kill_command], $server); - $deployment->addLogEntry('Deployment container stopped.'); - } else { - $deployment->addLogEntry('Deployment container not yet started. Will be cancelled when job checks status.'); - } + if ($containerExists && str($containerExists)->trim()->isNotEmpty()) { + instant_remote_process([$kill_command], $server); + $deployment->addLogEntry('Deployment container stopped.'); + } else { + $deployment->addLogEntry('Deployment container not yet started. Will be cancelled when job checks status.'); + } - // Kill running process if process ID exists - if ($deployment->current_process_id) { - try { + // Kill running process if process ID exists + if ($deployment->current_process_id) { $processKillCommand = "kill -9 {$deployment->current_process_id}"; instant_remote_process([$processKillCommand], $server); - } catch (\Throwable $e) { - // Process might already be gone } } + } catch (\Throwable $e) { + \Log::warning("Failed to clean up cancelled deployment {$deployment->id}: {$e->getMessage()}"); } auditLog('api.deployment.cancelled', [ 'team_id' => $teamId, 'deployment_uuid' => $deployment->deployment_uuid, - 'application_id' => $application?->id, - 'application_uuid' => $application?->uuid, + 'application_id' => $deployment->application_id, + 'application_uuid' => $deployment->application?->uuid, 'server_id' => $deployment->server_id, ]); @@ -301,6 +315,14 @@ class DeployController extends Controller return response()->json([ 'message' => 'Failed to cancel deployment: '.$e->getMessage(), ], 500); + } finally { + if ($cancelled) { + try { + next_after_cancel($deploymentServer); + } catch (\Throwable $e) { + \Log::warning("Failed to advance deployment queue after cancelling deployment {$deployment->id}: {$e->getMessage()}"); + } + } } } diff --git a/app/Jobs/DeleteResourceJob.php b/app/Jobs/DeleteResourceJob.php index 436bf788bd..124cc16cca 100644 --- a/app/Jobs/DeleteResourceJob.php +++ b/app/Jobs/DeleteResourceJob.php @@ -158,12 +158,15 @@ class DeleteResourceJob implements ShouldBeEncrypted, ShouldQueue ]) ->get(); + $cancelledDeployments = 0; + foreach ($activeDeployments as $activeDeployment) { try { // Mark deployment as cancelled $activeDeployment->update([ 'status' => ApplicationDeploymentStatus::CANCELLED_BY_USER->value, ]); + $cancelledDeployments++; // Add cancellation log entry $activeDeployment->addLogEntry('Deployment cancelled: Pull request closed.', 'stderr'); @@ -186,6 +189,14 @@ class DeleteResourceJob implements ShouldBeEncrypted, ShouldQueue } } + if ($cancelledDeployments > 0) { + try { + next_after_cancel($server); + } catch (\Throwable $e) { + \Log::warning("Failed to advance deployment queue after deleting preview {$this->resource->id}: {$e->getMessage()}"); + } + } + try { if ($server->isSwarm()) { $escapedStackName = escapeshellarg("{$application->uuid}-{$pull_request_id}"); diff --git a/app/Mcp/Tools/CancelDeployment.php b/app/Mcp/Tools/CancelDeployment.php index bbec091265..1133f34291 100644 --- a/app/Mcp/Tools/CancelDeployment.php +++ b/app/Mcp/Tools/CancelDeployment.php @@ -104,6 +104,13 @@ class CancelDeployment extends Tool 'server_id' => $deployment->server_id, ]); + try { + $deploymentServer = Server::whereTeamId($teamId)->find($deployment->server_id); + next_after_cancel($deploymentServer); + } catch (\Throwable $e) { + \Log::warning("Failed to advance deployment queue after cancelling deployment {$deployment->id}: {$e->getMessage()}"); + } + return $this->mcpSuccess($request, $this->respond([ 'ok' => true, 'message' => 'Deployment cancelled successfully.', diff --git a/tests/Feature/Api/DeploymentCancellationApiTest.php b/tests/Feature/Api/DeploymentCancellationApiTest.php index 046af059a5..81087a68dd 100644 --- a/tests/Feature/Api/DeploymentCancellationApiTest.php +++ b/tests/Feature/Api/DeploymentCancellationApiTest.php @@ -1,17 +1,32 @@ 0]); + config([ + 'cache.default' => 'array', + 'session.driver' => 'array', + 'queue.default' => 'sync', + 'app.maintenance.driver' => 'file', + ]); + + InstanceSettings::unguarded(fn () => InstanceSettings::updateOrCreate(['id' => 0], ['is_api_enabled' => true])); // Create a team with owner $this->team = Team::factory()->create(); @@ -119,23 +134,104 @@ describe('POST /api/v1/deployments/{uuid}/cancel', function () { }); test('cancels queued deployment and updates status in database', function () { + $otherTeam = Team::factory()->create(); + $buildServer = Server::factory()->create(['team_id' => $otherTeam->id]); $deployment = ApplicationDeploymentQueue::create([ 'deployment_uuid' => 'queued-deployment-uuid', 'application_id' => 1, 'server_id' => $this->server->id, + 'build_server_id' => $buildServer->id, 'status' => ApplicationDeploymentStatus::QUEUED->value, ]); - $this->withHeaders([ + $response = $this->withHeaders([ 'Authorization' => 'Bearer '.$this->bearerToken, 'Content-Type' => 'application/json', ])->postJson("/api/v1/deployments/{$deployment->deployment_uuid}/cancel"); - // The controller updates status before SSH calls, so DB state is always correct + $response->assertSuccessful()->assertJson([ + 'message' => 'Deployment cancelled successfully.', + 'deployment_uuid' => $deployment->deployment_uuid, + 'status' => ApplicationDeploymentStatus::CANCELLED_BY_USER->value, + ]); + $deployment->refresh(); expect($deployment->status)->toBe(ApplicationDeploymentStatus::CANCELLED_BY_USER->value); }); + test('starts the next queued deployment after cancellation', function () { + Queue::fake(); + + $otherTeam = Team::factory()->create(); + $buildServer = Server::factory()->create(['team_id' => $otherTeam->id]); + $destination = StandaloneDocker::where('server_id', $this->server->id)->firstOrFail(); + $project = Project::factory()->create(['team_id' => $this->team->id]); + $environment = Environment::factory()->create(['project_id' => $project->id]); + $application = Application::factory()->create([ + 'environment_id' => $environment->id, + 'destination_id' => $destination->id, + 'destination_type' => $destination->getMorphClass(), + ]); + $deployment = ApplicationDeploymentQueue::create([ + 'deployment_uuid' => 'cancelled-queue-head-uuid', + 'application_id' => $application->id, + 'server_id' => $this->server->id, + 'build_server_id' => $buildServer->id, + 'destination_id' => $destination->id, + 'commit' => 'first-commit', + 'pull_request_id' => 0, + 'status' => ApplicationDeploymentStatus::IN_PROGRESS->value, + ]); + $nextDeployment = ApplicationDeploymentQueue::create([ + 'deployment_uuid' => 'next-queued-deployment-uuid', + 'application_id' => $application->id, + 'server_id' => $this->server->id, + 'destination_id' => $destination->id, + 'commit' => 'second-commit', + 'pull_request_id' => 0, + 'status' => ApplicationDeploymentStatus::QUEUED->value, + ]); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + 'Content-Type' => 'application/json', + ])->postJson("/api/v1/deployments/{$deployment->deployment_uuid}/cancel"); + + $response->assertSuccessful(); + expect($nextDeployment->fresh()->status)->toBe(ApplicationDeploymentStatus::IN_PROGRESS->value); + Queue::assertPushed(ApplicationDeploymentJob::class, fn (ApplicationDeploymentJob $job) => $job->application_deployment_queue_id === $nextDeployment->id); + }); + + test('updates only a still cancellable deployment and treats cleanup as best effort', function () { + Process::fake(fn () => throw new RuntimeException('SSH unavailable')); + + $deployment = ApplicationDeploymentQueue::create([ + 'deployment_uuid' => 'atomic-cancellation-uuid', + 'application_id' => 1, + 'server_id' => $this->server->id, + 'status' => ApplicationDeploymentStatus::IN_PROGRESS->value, + ]); + $updates = []; + DB::listen(function ($query) use (&$updates) { + if (str_starts_with(strtolower(ltrim($query->sql)), 'update')) { + $updates[] = strtolower($query->sql); + } + }); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + 'Content-Type' => 'application/json', + ])->postJson("/api/v1/deployments/{$deployment->deployment_uuid}/cancel"); + + $response->assertSuccessful(); + expect($deployment->fresh()->status)->toBe(ApplicationDeploymentStatus::CANCELLED_BY_USER->value) + ->and(collect($updates)->contains( + fn (string $sql) => str_contains($sql, 'application_deployment_queues') + && str_contains($sql, 'status') + && str_contains($sql, ' in '), + ))->toBeTrue(); + }); + test('cancels in-progress deployment and updates status in database', function () { $deployment = ApplicationDeploymentQueue::create([ 'deployment_uuid' => 'in-progress-deployment-uuid', diff --git a/tests/Feature/ApplicationPreviewQueueAdvancementTest.php b/tests/Feature/ApplicationPreviewQueueAdvancementTest.php new file mode 100644 index 0000000000..3048398084 --- /dev/null +++ b/tests/Feature/ApplicationPreviewQueueAdvancementTest.php @@ -0,0 +1,94 @@ + InstanceSettings::firstOrCreate(['id' => 0])); + + $this->team = Team::factory()->create(); + $this->server = Server::factory()->create(['team_id' => $this->team->id]); + $this->server->settings->update([ + 'is_reachable' => true, + 'is_usable' => true, + 'force_disabled' => false, + ]); + $this->destination = StandaloneDocker::where('server_id', $this->server->id)->firstOrFail(); + $project = Project::factory()->create(['team_id' => $this->team->id]); + $environment = $project->environments()->first() + ?? Environment::factory()->create(['project_id' => $project->id]); + $this->application = Application::factory()->create([ + 'environment_id' => $environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + ]); + $this->preview = ApplicationPreview::create([ + 'application_id' => $this->application->id, + 'pull_request_id' => 42, + 'pull_request_html_url' => 'https://github.com/example/repository/pull/42', + 'fqdn' => 'https://pr-42.example.com', + ]); + + Process::fake(['*' => Process::result(output: '')]); + Queue::fake(); +}); + +function createPreviewDeploymentsForQueueAdvancementTest(): array +{ + $activeDeployment = ApplicationDeploymentQueue::create([ + 'application_id' => test()->application->id, + 'deployment_uuid' => 'preview-active-'.fake()->uuid(), + 'status' => 'in_progress', + 'server_id' => test()->server->id, + 'destination_id' => test()->destination->id, + 'commit' => 'preview-commit', + 'pull_request_id' => 42, + ]); + $nextDeployment = ApplicationDeploymentQueue::create([ + 'application_id' => test()->application->id, + 'deployment_uuid' => 'preview-next-'.fake()->uuid(), + 'status' => 'queued', + 'server_id' => test()->server->id, + 'destination_id' => test()->destination->id, + 'commit' => 'next-commit', + 'pull_request_id' => 0, + ]); + + return [$activeDeployment, $nextDeployment]; +} + +test('preview cleanup advances the deployment queue after cancelling active deployments', function () { + [$activeDeployment, $nextDeployment] = createPreviewDeploymentsForQueueAdvancementTest(); + + CleanupPreviewDeployment::run($this->application, 42, $this->preview); + + expect($activeDeployment->fresh()->status)->toBe('cancelled-by-user') + ->and($nextDeployment->fresh()->status)->toBe('in_progress'); + Queue::assertPushed(ApplicationDeploymentJob::class, fn (ApplicationDeploymentJob $job) => $job->application_deployment_queue_id === $nextDeployment->id); +}); + +test('deleting a preview advances the deployment queue after cancelling active deployments', function () { + [$activeDeployment, $nextDeployment] = createPreviewDeploymentsForQueueAdvancementTest(); + + (new DeleteResourceJob($this->preview))->handle(); + + expect($activeDeployment->fresh()->status)->toBe('cancelled-by-user') + ->and($nextDeployment->fresh()->status)->toBe('in_progress') + ->and(ApplicationPreview::withTrashed()->find($this->preview->id))->toBeNull(); + Queue::assertPushed(ApplicationDeploymentJob::class, fn (ApplicationDeploymentJob $job) => $job->application_deployment_queue_id === $nextDeployment->id); +}); diff --git a/tests/Feature/Mcp/McpReadToolsTest.php b/tests/Feature/Mcp/McpReadToolsTest.php index b755e75b98..c3433bf455 100644 --- a/tests/Feature/Mcp/McpReadToolsTest.php +++ b/tests/Feature/Mcp/McpReadToolsTest.php @@ -1,5 +1,6 @@ 'array', + 'session.driver' => 'array', + 'queue.default' => 'sync', + 'app.maintenance.driver' => 'file', + ]); + InstanceSettings::query()->where('id', 0)->delete(); InstanceSettings::query()->delete(); $settings = new InstanceSettings(['is_mcp_server_enabled' => true]); @@ -1744,6 +1753,7 @@ test('cancel_deployment cancels team deployment and rejects other team', functio Process::fake([ '*' => Process::result(output: ''), ]); + Queue::fake(); $deployment = ApplicationDeploymentQueue::create([ 'application_id' => $this->application->id, @@ -1755,6 +1765,17 @@ test('cancel_deployment cancels team deployment and rejects other team', functio 'commit' => 'abc', 'current_process_id' => '12345', ]); + $nextDeployment = ApplicationDeploymentQueue::create([ + 'application_id' => $this->application->id, + 'deployment_uuid' => 'dep-next-'.fake()->uuid(), + 'status' => 'queued', + 'server_id' => $this->server->id, + 'destination_id' => $this->destination->id, + 'application_name' => $this->application->name, + 'server_name' => $this->server->name, + 'commit' => 'def', + 'pull_request_id' => 0, + ]); $token = $this->user->createToken('mcp-cancel', ['read', 'deploy'])->plainTextToken; $ok = test()->withHeaders([ @@ -1777,6 +1798,8 @@ test('cancel_deployment cancels team deployment and rejects other team', functio expect($body['data']['ok'])->toBeTrue() ->and($body['data']['status'])->toBe('cancelled-by-user'); expect($deployment->fresh()->status)->toBe('cancelled-by-user'); + expect($nextDeployment->fresh()->status)->toBe('in_progress'); + Queue::assertPushed(ApplicationDeploymentJob::class, fn (ApplicationDeploymentJob $job) => $job->application_deployment_queue_id === $nextDeployment->id); $otherTeam = Team::factory()->create(); $otherServer = Server::factory()->create(['team_id' => $otherTeam->id]); From db1316f878d3238d3cbdfe386600136783da1441 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Mon, 17 Aug 2026 17:00:00 +0200 Subject: [PATCH 12/42] fix(backups): render form for directory-only targets (#11332) --- app/Livewire/Project/Application/Backup/Create.php | 2 +- tests/Feature/VolumeBackupTest.php | 12 ++++++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/app/Livewire/Project/Application/Backup/Create.php b/app/Livewire/Project/Application/Backup/Create.php index 68115e7751..f26d81b887 100644 --- a/app/Livewire/Project/Application/Backup/Create.php +++ b/app/Livewire/Project/Application/Backup/Create.php @@ -82,7 +82,7 @@ class Create extends Component 'type' => 'Directory', 'name' => $directory->fs_path, ]); - $this->targets = $volumes->concat($directories)->values(); + $this->targets = collect($volumes->concat($directories)->all())->values(); $this->targetKey = $this->selectedTargetKey ?? data_get($this->targets->first(), 'key'); $this->loadSelectedBackup(); } diff --git a/tests/Feature/VolumeBackupTest.php b/tests/Feature/VolumeBackupTest.php index 88e679a7c7..4968945916 100644 --- a/tests/Feature/VolumeBackupTest.php +++ b/tests/Feature/VolumeBackupTest.php @@ -222,6 +222,18 @@ it('creates a scheduled backup for a preselected application directory', functio expect(ScheduledVolumeBackup::query()->sole()->backupable->is($directory))->toBeTrue(); }); +it('renders the backup form for an application with only a directory target', function () { + $team = Team::factory()->create(); + signInForVolumeBackups($this, $team); + [$application, $volume] = createVolumeBackupApplication($team); + $volume->delete(); + $directory = createApplicationBackupDirectory($application); + + Livewire::test(CreateScheduledVolumeBackup::class, ['application' => $application]) + ->assertSet('targetKey', 'directory:'.$directory->id) + ->assertSuccessful(); +}); + it('rejects files and directory mounts owned by another application as backup targets', function () { $team = Team::factory()->create(); signInForVolumeBackups($this, $team); From 5c6defbc7618008529e807dfd8ab7f77152d1a5d Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Mon, 17 Aug 2026 18:29:09 +0200 Subject: [PATCH 13/42] fix(backups): honor full dumps and selected storage deletion (#11331) --- app/Jobs/DatabaseBackupJob.php | 55 ++++++++++--------- .../Project/Database/BackupExecutions.php | 26 ++++++--- app/Models/ScheduledDatabaseBackup.php | 1 + tests/Feature/Jobs/DatabaseBackupJobTest.php | 46 ++++++++++++++++ 4 files changed, 92 insertions(+), 36 deletions(-) diff --git a/app/Jobs/DatabaseBackupJob.php b/app/Jobs/DatabaseBackupJob.php index 82e35b73ce..1838feb9e7 100644 --- a/app/Jobs/DatabaseBackupJob.php +++ b/app/Jobs/DatabaseBackupJob.php @@ -279,33 +279,10 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue } else { return; } - } else { - if (str($databaseType)->contains('postgres')) { - // Format: db1,db2,db3 - $databasesToBackup = explode(',', $databasesToBackup); - $databasesToBackup = array_map('trim', $databasesToBackup); - } elseif (str($databaseType)->contains('mongo')) { - // Format: db1:collection1,collection2|db2:collection3,collection4 - // Only explode if it's a string, not if it's already an array - if (is_string($databasesToBackup)) { - $databasesToBackup = explode('|', $databasesToBackup); - $databasesToBackup = array_map('trim', $databasesToBackup); - } - } elseif (str($databaseType)->contains('mysql')) { - // Format: db1,db2,db3 - $databasesToBackup = explode(',', $databasesToBackup); - $databasesToBackup = array_map('trim', $databasesToBackup); - } elseif (str($databaseType)->contains('mariadb')) { - // Format: db1,db2,db3 - $databasesToBackup = explode(',', $databasesToBackup); - $databasesToBackup = array_map('trim', $databasesToBackup); - } elseif ($this->database instanceof StandaloneClickhouse) { - // Format: db1,db2,db3 - $databasesToBackup = explode(',', $databasesToBackup); - $databasesToBackup = array_map('trim', $databasesToBackup); - } else { - return; - } + } + $databasesToBackup = $this->databasesToBackup($databaseType, $databasesToBackup); + if ($databasesToBackup === []) { + return; } $this->backup_dir = backup_dir().'/databases/'.str($this->team->name)->slug().'-'.$this->team->id.'/'.$this->directory_name; if ($this->database->name === 'coolify-db') { @@ -600,6 +577,30 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue } } + /** @return array */ + private function databasesToBackup(string $databaseType, string|array $databases): array + { + $type = str($databaseType); + + if ($this->backup->dump_all && $type->contains(['postgres', 'mysql', 'mariadb'])) { + return ['all']; + } + + if (is_array($databases)) { + return $databases; + } + + if ($type->contains('mongo')) { + return array_map('trim', explode('|', $databases)); + } + + if ($type->contains(['postgres', 'mysql', 'mariadb', 'clickhouse'])) { + return array_map('trim', explode(',', $databases)); + } + + return []; + } + private function backup_standalone_postgresql(string $database): void { try { diff --git a/app/Livewire/Project/Database/BackupExecutions.php b/app/Livewire/Project/Database/BackupExecutions.php index 41fb1681bf..73877a945e 100644 --- a/app/Livewire/Project/Database/BackupExecutions.php +++ b/app/Livewire/Project/Database/BackupExecutions.php @@ -98,26 +98,34 @@ class BackupExecutions extends Component return; } - $server = $execution->scheduledDatabaseBackup->database->getMorphClass() === ServiceDatabase::class - ? $execution->scheduledDatabaseBackup->database->service->destination->server - : $execution->scheduledDatabaseBackup->database->destination->server; - try { - if ($execution->filename) { - deleteBackupsLocally($execution->filename, $server); + $deleteFromS3 = in_array('delete_backup_s3', $selectedActions, true); - if ($this->delete_backup_s3 && $execution->scheduledDatabaseBackup->s3) { - deleteBackupsS3($execution->filename, $execution->scheduledDatabaseBackup->s3); + if ($execution->filename && ! $execution->local_storage_deleted) { + $server = $this->backup->server(); + if (! $server) { + throw new \RuntimeException('The backup server is unavailable.'); } + + deleteBackupsLocally($execution->filename, $server, throwError: true); + } + + if ($deleteFromS3 && $execution->s3_uploaded && ! $execution->s3_storage_deleted) { + if (! $execution->scheduledDatabaseBackup->s3) { + throw new \RuntimeException('The S3 storage is unavailable.'); + } + + deleteBackupsS3($execution->filename, $execution->scheduledDatabaseBackup->s3); } $execution->delete(); + $this->delete_backup_s3 = false; $this->dispatch('success', 'Backup deleted.'); $this->refreshBackupExecutions(); } catch (\Exception $e) { $this->dispatch('error', 'Failed to delete backup: '.$e->getMessage()); - return true; + return false; } return true; diff --git a/app/Models/ScheduledDatabaseBackup.php b/app/Models/ScheduledDatabaseBackup.php index 4038c6288c..e41c793c86 100644 --- a/app/Models/ScheduledDatabaseBackup.php +++ b/app/Models/ScheduledDatabaseBackup.php @@ -11,6 +11,7 @@ class ScheduledDatabaseBackup extends BaseModel protected function casts(): array { return [ + 'dump_all' => 'boolean', 'database_backup_retention_max_storage_locally' => 'float', 'database_backup_retention_max_storage_s3' => 'float', ]; diff --git a/tests/Feature/Jobs/DatabaseBackupJobTest.php b/tests/Feature/Jobs/DatabaseBackupJobTest.php index 7e47126ab1..23e1bbd8d2 100644 --- a/tests/Feature/Jobs/DatabaseBackupJobTest.php +++ b/tests/Feature/Jobs/DatabaseBackupJobTest.php @@ -44,6 +44,14 @@ test('scheduled database backup execution model casts storage deletion fields co expect($casts['s3_storage_deleted'])->toBe('boolean'); }); +test('scheduled database backup casts full dump selection to boolean', function () { + $model = new ScheduledDatabaseBackup; + + expect($model->getCasts())->toMatchArray(['dump_all' => 'boolean']) + ->and((new ScheduledDatabaseBackup(['dump_all' => '0']))->dump_all)->toBeFalse() + ->and((new ScheduledDatabaseBackup(['dump_all' => '1']))->dump_all)->toBeTrue(); +}); + test('upload_to_s3 throws exception and disables s3 when storage is null', function () { $backup = ScheduledDatabaseBackup::create([ 'frequency' => '0 0 * * *', @@ -362,3 +370,41 @@ test('all dump all database commands use shared helper compression', function () ->and(substr_count($source, '$this->buildCompressedDumpCommand($dumpCommand)'))->toBe(2) ->and($source)->not->toContain('| gzip >'); }); + +test('full database dumps create one logical all-databases archive regardless of saved database names', function (string $databaseType) { + $backup = new ScheduledDatabaseBackup([ + 'dump_all' => true, + 'databases_to_backup' => 'default,analytics', + ]); + $job = new DatabaseBackupJob($backup); + + $databases = (new ReflectionClass($job)) + ->getMethod('databasesToBackup') + ->invoke($job, $databaseType, $backup->databases_to_backup); + + expect($databases)->toBe(['all']); +})->with(['postgresql', 'mysql', 'mariadb']); + +test('specific database dumps keep every selected database', function (string $databaseType) { + $backup = new ScheduledDatabaseBackup([ + 'dump_all' => false, + 'databases_to_backup' => 'default, analytics', + ]); + $job = new DatabaseBackupJob($backup); + + $databases = (new ReflectionClass($job)) + ->getMethod('databasesToBackup') + ->invoke($job, $databaseType, $backup->databases_to_backup); + + expect($databases)->toBe(['default', 'analytics']); +})->with(['postgresql', 'mysql', 'mariadb']); + +test('individual database backup deletion surfaces local failures and honors selected S3 deletion', function () { + $source = file_get_contents(app_path('Livewire/Project/Database/BackupExecutions.php')); + + expect($source) + ->toContain("in_array('delete_backup_s3', \$selectedActions, true)") + ->toContain('deleteBackupsLocally($execution->filename, $server, throwError: true)') + ->toContain("throw new \\RuntimeException('The backup server is unavailable.')") + ->not->toContain('deleteBackupsLocally($execution->filename, $server);'); +}); From 43cd1f4c0dfc883ab8c8cdea923b5be2aa4cd0c3 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Mon, 17 Aug 2026 20:56:02 +0200 Subject: [PATCH 14/42] chore(release): bump Coolify versions to 4.3.8 --- config/constants.php | 2 +- other/nightly/versions.json | 4 ++-- tests/Unit/ProductionImageWorkflowTest.php | 6 +++--- versions.json | 4 ++-- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/config/constants.php b/config/constants.php index 76e34334c2..bc21514677 100644 --- a/config/constants.php +++ b/config/constants.php @@ -2,7 +2,7 @@ return [ 'coolify' => [ - 'version' => env('COOLIFY_VERSION') ?: '4.3.7', + 'version' => env('COOLIFY_VERSION') ?: '4.3.8', 'helper_version' => '1.0.15', 'realtime_version' => '1.0.17', 'railpack_version' => '0.23.0', diff --git a/other/nightly/versions.json b/other/nightly/versions.json index 2f449eed8c..ebc1151c32 100644 --- a/other/nightly/versions.json +++ b/other/nightly/versions.json @@ -1,10 +1,10 @@ { "coolify": { "v4": { - "version": "4.3.7" + "version": "4.3.8" }, "nightly": { - "version": "4.3.8" + "version": "4.3.9" }, "helper": { "version": "1.0.15" diff --git a/tests/Unit/ProductionImageWorkflowTest.php b/tests/Unit/ProductionImageWorkflowTest.php index 754eca1c30..e57587f289 100644 --- a/tests/Unit/ProductionImageWorkflowTest.php +++ b/tests/Unit/ProductionImageWorkflowTest.php @@ -23,9 +23,9 @@ it('publishes v4 branch builds under the commit sha with a traceable internal ve ->toContain('ARG COOLIFY_VERSION') ->toContain('ENV COOLIFY_VERSION=${COOLIFY_VERSION}') ->and($constants) - ->toContain("'version' => env('COOLIFY_VERSION') ?: '4.3.7'") - ->and($versions['coolify']['v4']['version'])->toBe('4.3.7') - ->and($versions['coolify']['nightly']['version'])->toBe('4.3.8') + ->toContain("'version' => env('COOLIFY_VERSION') ?: '4.3.8'") + ->and($versions['coolify']['v4']['version'])->toBe('4.3.8') + ->and($versions['coolify']['nightly']['version'])->toBe('4.3.9') ->and($nightlyVersions)->toBe($versions); }); diff --git a/versions.json b/versions.json index 2f449eed8c..ebc1151c32 100644 --- a/versions.json +++ b/versions.json @@ -1,10 +1,10 @@ { "coolify": { "v4": { - "version": "4.3.7" + "version": "4.3.8" }, "nightly": { - "version": "4.3.8" + "version": "4.3.9" }, "helper": { "version": "1.0.15" From 9d64b30612af2d80bf32ff46a09d9d9bdb5f3b64 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Mon, 17 Aug 2026 21:26:57 +0200 Subject: [PATCH 15/42] fix(ui): improve listbox hover state and Traefik redirects Add an opaque hover background for listbox triggers and preserve single-escaped Traefik redirect capture groups when generating application labels. --- bootstrap/helpers/docker.php | 11 ++++++++--- resources/css/app.css | 4 ++++ tests/Feature/ListboxTriggerTruncationTest.php | 7 +++++++ tests/Unit/TraefikServiceNameSegmentTest.php | 12 ++++++++++++ 4 files changed, 31 insertions(+), 3 deletions(-) diff --git a/bootstrap/helpers/docker.php b/bootstrap/helpers/docker.php index 3be2ae008b..d4466d3893 100644 --- a/bootstrap/helpers/docker.php +++ b/bootstrap/helpers/docker.php @@ -563,7 +563,7 @@ function fqdnLabelsForCaddy(string $network, string $uuid, Collection $domains, return $labels->sort(); } -function fqdnLabelsForTraefik(string $uuid, Collection $domains, bool $is_force_https_enabled = false, $onlyPort = null, ?Collection $serviceLabels = null, ?bool $is_gzip_enabled = true, ?bool $is_stripprefix_enabled = true, ?string $service_name = null, bool $generate_unique_uuid = false, ?string $image = null, string $redirect_direction = 'both', bool $is_http_basic_auth_enabled = false, ?string $http_basic_auth_username = null, ?string $http_basic_auth_password = null, ?Collection $noindex_domains = null) +function fqdnLabelsForTraefik(string $uuid, Collection $domains, bool $is_force_https_enabled = false, $onlyPort = null, ?Collection $serviceLabels = null, ?bool $is_gzip_enabled = true, ?bool $is_stripprefix_enabled = true, ?string $service_name = null, bool $generate_unique_uuid = false, ?string $image = null, string $redirect_direction = 'both', bool $is_http_basic_auth_enabled = false, ?string $http_basic_auth_username = null, ?string $http_basic_auth_password = null, ?Collection $noindex_domains = null, bool $escape_redirect_replacement_for_compose = true) { $labels = collect([]); $labels->push('traefik.enable=true'); @@ -646,14 +646,15 @@ function fqdnLabelsForTraefik(string $uuid, Collection $domains, bool $is_force_ $to_www_name = "{$loop}-{$uuid}-to-www"; $to_non_www_name = "{$loop}-{$uuid}-to-non-www"; + $redirect_capture_prefix = $escape_redirect_replacement_for_compose ? '$$' : '$'; $redirect_to_non_www = [ "traefik.http.middlewares.{$to_non_www_name}.redirectregex.regex=^(http|https)://www\.(.+)", - "traefik.http.middlewares.{$to_non_www_name}.redirectregex.replacement=\$\${1}://\$\${2}", + "traefik.http.middlewares.{$to_non_www_name}.redirectregex.replacement={$redirect_capture_prefix}{1}://{$redirect_capture_prefix}{2}", "traefik.http.middlewares.{$to_non_www_name}.redirectregex.permanent=false", ]; $redirect_to_www = [ "traefik.http.middlewares.{$to_www_name}.redirectregex.regex=^(http|https)://(?:www\.)?(.+)", - "traefik.http.middlewares.{$to_www_name}.redirectregex.replacement=\$\${1}://www.\$\${2}", + "traefik.http.middlewares.{$to_www_name}.redirectregex.replacement={$redirect_capture_prefix}{1}://www.{$redirect_capture_prefix}{2}", "traefik.http.middlewares.{$to_www_name}.redirectregex.permanent=false", ]; if ($schema === 'https') { @@ -876,6 +877,7 @@ function generateLabelsApplication(Application $application, ?ApplicationPreview http_basic_auth_username: $application->http_basic_auth_username, http_basic_auth_password: $application->http_basic_auth_password, noindex_domains: $noindexDomains, + escape_redirect_replacement_for_compose: false, )); break; } @@ -892,6 +894,7 @@ function generateLabelsApplication(Application $application, ?ApplicationPreview http_basic_auth_username: $application->http_basic_auth_username, http_basic_auth_password: $application->http_basic_auth_password, noindex_domains: $noindexDomains, + escape_redirect_replacement_for_compose: false, )); $labels = $labels->merge(fqdnLabelsForCaddy( network: $application->destination->network, @@ -932,6 +935,7 @@ function generateLabelsApplication(Application $application, ?ApplicationPreview http_basic_auth_username: $application->http_basic_auth_username, http_basic_auth_password: $application->http_basic_auth_password, noindex_domains: $noindexDomains, + escape_redirect_replacement_for_compose: false, )); break; case ProxyTypes::CADDY->value: @@ -962,6 +966,7 @@ function generateLabelsApplication(Application $application, ?ApplicationPreview http_basic_auth_username: $application->http_basic_auth_username, http_basic_auth_password: $application->http_basic_auth_password, noindex_domains: $noindexDomains, + escape_redirect_replacement_for_compose: false, )); $labels = $labels->merge(fqdnLabelsForCaddy( network: $application->destination->network, diff --git a/resources/css/app.css b/resources/css/app.css index 95e0207ce2..566f290317 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -1903,6 +1903,10 @@ html[data-theme="custom"] textarea:disabled { color: var(--color-fg); } +.listbox-trigger:hover { + background: var(--coollabs-fill); +} + .listbox-trigger:disabled { cursor: not-allowed; opacity: 0.5; diff --git a/tests/Feature/ListboxTriggerTruncationTest.php b/tests/Feature/ListboxTriggerTruncationTest.php index a64a97dd8a..a35ff8cbee 100644 --- a/tests/Feature/ListboxTriggerTruncationTest.php +++ b/tests/Feature/ListboxTriggerTruncationTest.php @@ -22,6 +22,13 @@ test('listbox trigger height matches shared inputs', function () { ->toMatch('/\.application-settings-workspace \.listbox-trigger[^}]*height: 2rem;/s'); }); +test('listbox trigger uses an opaque background on hover', function () { + $css = file_get_contents(resource_path('css/app.css')); + + expect($css) + ->toMatch('/\.listbox-trigger:hover \{[^}]*background: var\(--coollabs-fill\);/s'); +}); + test('listbox component uses shared trigger label truncation', function () { $html = Blade::render(<<<'BLADE' toContain("https-0-{$uuid}-another-service-{$hash}.rule="); }); + +test('application labels keep redirect capture groups single escaped before compose generation', function () { + $labels = fqdnLabelsForTraefik( + uuid: 'application-uuid', + domains: collect(['https://example.com']), + redirect_direction: 'www', + escape_redirect_replacement_for_compose: false, + ); + + expect($labels) + ->toContain('traefik.http.middlewares.0-application-uuid-to-www.redirectregex.replacement=${1}://www.${2}'); +}); From 92d6fe9d4e3261e0447dc3a8e6024f8b4587b805 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Mon, 17 Aug 2026 21:38:44 +0200 Subject: [PATCH 16/42] fix(ui): use solid surfaces for deployment indicator states Remove deployment opacity fading and translucent dark-theme surfaces, and drop the global listbox hover background rule. --- app/Livewire/DeploymentsIndicator.php | 6 ------ resources/css/app.css | 4 ---- .../livewire/deployments-indicator.blade.php | 20 +++++++++---------- .../DeploymentsIndicatorLayoutTest.php | 15 +++++++++++++- .../Feature/ListboxTriggerTruncationTest.php | 7 ------- 5 files changed, 23 insertions(+), 29 deletions(-) diff --git a/app/Livewire/DeploymentsIndicator.php b/app/Livewire/DeploymentsIndicator.php index 235071dbe2..28c9a00c61 100644 --- a/app/Livewire/DeploymentsIndicator.php +++ b/app/Livewire/DeploymentsIndicator.php @@ -54,12 +54,6 @@ class DeploymentsIndicator extends Component return $this->deployments->count(); } - #[Computed] - public function shouldReduceOpacity(): bool - { - return request()->routeIs('project.application.deployment.*'); - } - public function toggleExpanded() { $this->expanded = ! $this->expanded; diff --git a/resources/css/app.css b/resources/css/app.css index 566f290317..95e0207ce2 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -1903,10 +1903,6 @@ html[data-theme="custom"] textarea:disabled { color: var(--color-fg); } -.listbox-trigger:hover { - background: var(--coollabs-fill); -} - .listbox-trigger:disabled { cursor: not-allowed; opacity: 0.5; diff --git a/resources/views/livewire/deployments-indicator.blade.php b/resources/views/livewire/deployments-indicator.blade.php index ece7e4b441..5b9e41f76f 100644 --- a/resources/views/livewire/deployments-indicator.blade.php +++ b/resources/views/livewire/deployments-indicator.blade.php @@ -1,18 +1,16 @@
@if ($this->shouldShow && $this->deploymentCount > 0) -
+
{{-- Expanded deployment list (above the pill) --}}
@@ -26,9 +24,9 @@ @endphp + class="flex items-start gap-3 rounded-lg border border-transparent p-3 transition-colors hover:border-neutral-200 hover:bg-neutral-50 hover:no-underline dark:border-coolgray-300 dark:hover:border-coolgray-400 dark:hover:bg-raised">
+ class="mt-0.5 flex size-8 shrink-0 items-center justify-center rounded-lg border border-neutral-200 bg-neutral-50 text-coollabs dark:border-coolgray-300 dark:bg-raised dark:text-warning"> @if ($deployment->status === 'in_progress')

{{ $deployment->server_name ?: '-' }} @if ($deployment->pull_request_id) - · + · PR #{{ $deployment->pull_request_id }} @endif

@@ -73,7 +71,7 @@ {{-- Collapsed pill --}}
diff --git a/resources/views/components/services/links.blade.php b/resources/views/components/services/links.blade.php index 5d78846288..32de3af618 100644 --- a/resources/views/components/services/links.blade.php +++ b/resources/views/components/services/links.blade.php @@ -2,15 +2,22 @@ $linkItemClasses = 'listbox-option justify-start! gap-2.5!'; @endphp -
$fullWidth]) x-data="{ open: false }" +
!$compact, + 'static' => $compact, + 'w-full' => $fullWidth, +]) x-data="{ open: false }" x-effect="$dispatch('resource-actions-toggled', { open })" @keydown.escape.window="open = false">
diff --git a/tests/Feature/ApplicationDomainsTest.php b/tests/Feature/ApplicationDomainsTest.php index fbe72230d1..50c71fe958 100644 --- a/tests/Feature/ApplicationDomainsTest.php +++ b/tests/Feature/ApplicationDomainsTest.php @@ -119,6 +119,27 @@ it('lists existing domains as individual rows', function () { expect(substr_count($html, 'this.$wire.updateRedirect('))->toBe(2); }); +it('shows the HTTP redirect control for HTTPS domains and persists changes', function () { + $this->application->update(['fqdn' => 'https://app.example.com']); + + Livewire::test(Domains::class, ['application' => $this->application->fresh()]) + ->assertSet('isForceHttpsEnabled', true) + ->assertSee('Redirect HTTP to HTTPS') + ->assertSee('Keep enabled when Cloudflare uses Full or Full (Strict) SSL.') + ->set('isForceHttpsEnabled', false) + ->call('updateForceHttps') + ->assertHasNoErrors(); + + expect($this->application->settings->fresh()->is_force_https_enabled)->toBeFalse(); +}); + +it('hides the HTTP redirect control for HTTP-only domains', function () { + $this->application->update(['fqdn' => 'http://app.example.com']); + + Livewire::test(Domains::class, ['application' => $this->application->fresh()]) + ->assertDontSee('Redirect HTTP to HTTPS'); +}); + it('shows one redirect direction control in each compose service header', function () { $this->application->update([ 'build_pack' => 'dockercompose', diff --git a/tests/Feature/ApplicationParserDockerComposeDomainsTest.php b/tests/Feature/ApplicationParserDockerComposeDomainsTest.php index 270927ca18..1b3e61be07 100644 --- a/tests/Feature/ApplicationParserDockerComposeDomainsTest.php +++ b/tests/Feature/ApplicationParserDockerComposeDomainsTest.php @@ -399,7 +399,7 @@ YAML; expect(json_decode($plainApplication->docker_compose_domains, true))->toBeNull(); }); -test('applicationParser selects the Coolify network for Traefik routed compose services', function () { +test('applicationParser does not force the private resource network for Traefik routed compose services', function () { $application = Application::factory()->create([ 'environment_id' => $this->environment->id, 'destination_id' => $this->destination->id, @@ -422,7 +422,7 @@ YAML, $parsedCompose = applicationParser($application); $labels = collect(data_get($parsedCompose, 'services.frontend.labels')); - expect($labels->values()->all())->toContain("traefik.docker.network={$application->uuid}"); + expect($labels->values()->all())->not->toContain("traefik.docker.network={$application->uuid}"); }); test('applicationParser preserves a user-selected Traefik network', function () { diff --git a/tests/Feature/Auth/TwoFactorChallengeAccessTest.php b/tests/Feature/Auth/TwoFactorChallengeAccessTest.php index 4f1dee08c5..f0dd97cc31 100644 --- a/tests/Feature/Auth/TwoFactorChallengeAccessTest.php +++ b/tests/Feature/Auth/TwoFactorChallengeAccessTest.php @@ -23,6 +23,18 @@ it('allows unauthenticated access to two-factor-challenge page', function () { expect($response->status())->toBeIn([200, 302]); }); +it('uses one mobile-friendly field for authenticator code paste and autofill', function () { + $challenge = file_get_contents(resource_path('views/auth/two-factor-challenge.blade.php')); + + expect($challenge) + ->toContain('name="code"') + ->toContain('autocomplete="one-time-code"') + ->toContain('inputmode="numeric"') + ->toContain('maxlength="6"') + ->toContain('@input="submitAuthenticatorCode($event)"') + ->not->toContain('x-for="(digit, index) in digits"'); +}); + it('includes two-factor-challenge in allowed paths for unsubscribed accounts', function () { $paths = allowedPathsForUnsubscribedAccounts(); diff --git a/tests/Feature/ListboxTriggerTruncationTest.php b/tests/Feature/ListboxTriggerTruncationTest.php index a64a97dd8a..d5f259a853 100644 --- a/tests/Feature/ListboxTriggerTruncationTest.php +++ b/tests/Feature/ListboxTriggerTruncationTest.php @@ -114,7 +114,8 @@ test('listbox forwards dynamic disabled state to its trigger', function () { ->toContain('x-model="selectedCloneProject"') ->toContain('x-model="selectedCloneEnvironment"') ->toContain('$wire.cloneTo(selectedCloneDestination)') - ->toContain('$wire.cloneTo(@js($resource->destination->uuid), selectedCloneEnvironment)') + ->toContain('$wire.cloneTo(currentDestinationUuid, selectedCloneEnvironment)') + ->not->toContain('$wire.cloneTo(@js(') ->toContain('x-bind:disabled="!selectedMoveProject || availableEnvironments.length === 0"'); }); diff --git a/tests/Feature/Mcp/McpEndpointTest.php b/tests/Feature/Mcp/McpEndpointTest.php index 240f5b2e14..01ba0ecf16 100644 --- a/tests/Feature/Mcp/McpEndpointTest.php +++ b/tests/Feature/Mcp/McpEndpointTest.php @@ -216,6 +216,13 @@ test('get_infrastructure_overview returns counts', function () { test('get_server scrubs sensitive nested data and exposes connection_timeout', function () { $server = Server::factory()->create(['team_id' => $this->team->id]); + $server->proxy->set('last_saved_proxy_configuration', <<<'YAML' +services: + traefik: + environment: + CF_DNS_API_TOKEN: plaintext-cloudflare-token +YAML); + $server->saveQuietly(); // creating hook auto-generates a sentinel_token; bump connection_timeout // via saveQuietly to avoid triggering restartSentinel. $server->settings->forceFill(['connection_timeout' => 42])->saveQuietly(); @@ -229,6 +236,9 @@ test('get_server scrubs sensitive nested data and exposes connection_timeout', f $raw = json_encode($body); expect($raw)->not->toContain('sentinel_token'); + expect($raw)->not->toContain('last_saved_proxy_configuration'); + expect($raw)->not->toContain('CF_DNS_API_TOKEN'); + expect($raw)->not->toContain('plaintext-cloudflare-token'); expect($raw)->not->toContain('"team_id"'); expect($raw)->not->toContain('"private_key_id"'); expect($body['data']['connection_timeout'])->toBe(42); diff --git a/tests/Feature/Mcp/McpReadToolsTest.php b/tests/Feature/Mcp/McpReadToolsTest.php index c3433bf455..6e146e7190 100644 --- a/tests/Feature/Mcp/McpReadToolsTest.php +++ b/tests/Feature/Mcp/McpReadToolsTest.php @@ -590,6 +590,51 @@ test('list_env_keys never returns values and is team scoped', function () { expect($denied->json('result.isError'))->toBeTrue(); }); +test('get_application omits free-form configuration that can contain secrets', function () { + $this->application->update([ + 'git_full_url' => 'https://oauth2:application-secret@example.com/repository.git', + 'build_command' => 'API_TOKEN=application-secret npm run build', + 'custom_docker_run_options' => '--env API_TOKEN=application-secret', + 'custom_nginx_configuration' => base64_encode('proxy_set_header Authorization "Bearer application-secret";'), + ]); + + $response = mcpReadCall('get_application', ['uuid' => $this->application->uuid]); + $response->assertOk(); + + $body = mcpReadJson($response); + $raw = json_encode($body); + + expect($body['data']['uuid'])->toBe($this->application->uuid) + ->and($raw)->not->toContain('application-secret') + ->and($raw)->not->toContain('git_full_url') + ->and($raw)->not->toContain('build_command') + ->and($raw)->not->toContain('custom_docker_run_options') + ->and($raw)->not->toContain('custom_nginx_configuration'); +}); + +test('get_database omits configuration blobs that can contain secrets', function () { + $database = StandalonePostgresql::create([ + 'name' => 'Secret-bearing config', + 'postgres_password' => 'database-password', + 'postgres_conf' => "primary_conninfo = 'password=database-config-secret'", + 'custom_docker_run_options' => '--env API_TOKEN=database-config-secret', + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + ]); + + $response = mcpReadCall('get_database', ['uuid' => $database->uuid]); + $response->assertOk(); + + $body = mcpReadJson($response); + $raw = json_encode($body); + + expect($body['data']['uuid'])->toBe($database->uuid) + ->and($raw)->not->toContain('database-config-secret') + ->and($raw)->not->toContain('postgres_conf') + ->and($raw)->not->toContain('custom_docker_run_options'); +}); + test('list_destinations and get_destination are team scoped', function () { $response = mcpReadCall('list_destinations'); $response->assertOk(); diff --git a/tests/Feature/ModelFillableCreationTest.php b/tests/Feature/ModelFillableCreationTest.php index 50b6e02a98..46d9c36daa 100644 --- a/tests/Feature/ModelFillableCreationTest.php +++ b/tests/Feature/ModelFillableCreationTest.php @@ -463,6 +463,7 @@ it('creates ServiceApplication with all fillable attributes', function () { 'is_include_timestamps' => true, 'is_gzip_enabled' => true, 'is_stripprefix_enabled' => true, + 'is_force_https_enabled' => false, 'last_online_at' => now()->toISOString(), 'is_migrated' => false, ]); @@ -473,6 +474,26 @@ it('creates ServiceApplication with all fillable attributes', function () { expect($svcApp->human_name)->toBe('Web Server'); expect($svcApp->image)->toBe('nginx:latest'); expect($svcApp->is_log_drain_enabled)->toBeTrue(); + expect($svcApp->is_force_https_enabled)->toBeFalse(); + expect($svcApp->isForceHttpsEnabled())->toBeFalse(); +}); + +it('enables HTTPS redirects for service applications by default', function () { + $service = Service::create([ + 'docker_compose_raw' => 'services: {}', + 'environment_id' => $this->environment->id, + 'server_id' => $this->server->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + ]); + + $serviceApplication = ServiceApplication::create([ + 'service_id' => $service->id, + 'name' => 'web-default-redirect', + ]); + + expect($serviceApplication->is_force_https_enabled)->toBeTrue(); + expect($serviceApplication->isForceHttpsEnabled())->toBeTrue(); }); it('creates ServiceDatabase with all fillable attributes', function () { diff --git a/tests/Feature/ResourceHeadingUnifiedNavbarTest.php b/tests/Feature/ResourceHeadingUnifiedNavbarTest.php index 81e596ebf1..bb9e888982 100644 --- a/tests/Feature/ResourceHeadingUnifiedNavbarTest.php +++ b/tests/Feature/ResourceHeadingUnifiedNavbarTest.php @@ -635,20 +635,31 @@ it('shows application Links as a compact badge beside the mobile status', functi ->toContain('between('class="w-full md:hidden"', 'class="hidden w-full items-center md:flex')->toString(); - - expect($mobileServiceSection) - ->toContain('toContain('full-width') - ->toContain('resource-heading-menus') - ->not->toContain('between('class="mb-3 w-full xl:hidden"', '
')->toString(); + $mobileServiceActions = str($service)->between('
', '@teleport')->toString(); + + expect($mobileServiceTitle) + ->toContain('toContain('toContain('relative flex w-full min-w-0 items-center gap-2') + ->toContain('compact') + ->not->toContain('full-width'); + + expect($mobileServiceActions) + ->not->toContain('toContain('public bool $compact = false'); + expect($serviceLinks) - ->toContain("'button w-full justify-between' => \$fullWidth") - ->toContain("'left-0! right-0! w-full! min-w-0! max-w-none!' => \$fullWidth") + ->toContain("'static' => \$compact") + ->toContain("'inline-flex h-6 shrink-0 items-center gap-1.5 rounded-full border border-neutral-200 bg-neutral-100 px-2 text-xs font-medium leading-none text-neutral-700 dark:border-white/[0.12] dark:bg-white/[0.07] dark:text-white' => \$compact") + ->toContain('@unless ($compact)') + ->toContain("'left-1/2! right-auto! w-[calc(100vw-2rem)]! max-w-md! min-w-0! -translate-x-1/2' => \$compact") ->toContain('toContain('No links available'); diff --git a/tests/Feature/ServiceApplicationsApiTest.php b/tests/Feature/ServiceApplicationsApiTest.php index d65967bd80..c8dd33a46b 100644 --- a/tests/Feature/ServiceApplicationsApiTest.php +++ b/tests/Feature/ServiceApplicationsApiTest.php @@ -199,6 +199,20 @@ describe('PATCH /api/v1/services/{uuid}/applications/{app_uuid}', function () { expect($ctx->serviceApplication->human_name)->toBe('Web UI'); }); + test('updates the HTTP to HTTPS redirect setting', function () { + config(['app.maintenance.driver' => 'file']); + $ctx = createServiceWithApplicationForApiTest($this); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + ])->patchJson("/api/v1/services/{$ctx->service->uuid}/applications/{$ctx->serviceApplication->uuid}", [ + 'is_force_https_enabled' => false, + ]); + + $response->assertSuccessful(); + expect($ctx->serviceApplication->fresh()->is_force_https_enabled)->toBeFalse(); + }); + test('returns 422 for invalid url scheme', function () { $ctx = createServiceWithApplicationForApiTest($this); diff --git a/tests/Feature/ServiceDomainsTest.php b/tests/Feature/ServiceDomainsTest.php index 36f602856f..62ea41d96d 100644 --- a/tests/Feature/ServiceDomainsTest.php +++ b/tests/Feature/ServiceDomainsTest.php @@ -125,6 +125,24 @@ it('groups configured domains and shows redirect settings in the table', functio ->and(substr_count($html, "id=\"service-domain-group-{$this->apiApp->id}\""))->toBe(1); }); +it('shows and persists the HTTP redirect control for HTTPS service applications', function () { + Livewire::test(Domains::class, ['service' => $this->service->fresh(['applications', 'server'])]) + ->assertSee('Redirect HTTP to HTTPS') + ->assertSee('Keep enabled when Cloudflare uses Full or Full (Strict) SSL.') + ->call('updateForceHttps', $this->apiApp->id, false) + ->assertHasNoErrors(); + + expect($this->apiApp->fresh()->is_force_https_enabled)->toBeFalse(); + expect($this->service->fresh()->docker_compose)->not->toContain('middlewares=redirect-to-https'); +}); + +it('hides the HTTP redirect control for HTTP-only service applications', function () { + $this->apiApp->update(['fqdn' => 'http://api.example.com']); + + Livewire::test(Domains::class, ['service' => $this->service->fresh(['applications', 'server'])]) + ->assertDontSee('Redirect HTTP to HTTPS'); +}); + it('shows one redirect control for each www and non-www pair', function () { $this->apiApp->update([ 'fqdn' => 'https://api.example.com,https://www.api.example.com,https://admin.example.com,https://www.admin.example.com', @@ -146,6 +164,18 @@ it('uses segmented fields when adding and editing service domains', function () ->not->toContain('placeholder="https://app.example.com"'); }); +it('resets the add domain dns gate when segmented domain fields change', function () { + Livewire::test(Domains::class, ['service' => $this->service->fresh(['applications', 'server'])]) + ->set('addDomainDnsFailed', true) + ->set('addDomainDnsMessage', 'DNS validation failed.') + ->set('forceSaveDns', true) + ->set('newDomainParts.host', 'web.example.com') + ->assertSet('newDomainPartsChanged', true) + ->assertSet('addDomainDnsFailed', false) + ->assertSet('addDomainDnsMessage', '') + ->assertSet('forceSaveDns', false); +}); + it('shows dns entries control next to Add', function () { Livewire::test(Domains::class, ['service' => $this->service->fresh(['applications', 'server'])]) ->assertSuccessful() diff --git a/tests/Feature/TraefikServiceDockerNetworkLabelTest.php b/tests/Feature/TraefikServiceDockerNetworkLabelTest.php index 4cf4ebc568..f1d7087a29 100644 --- a/tests/Feature/TraefikServiceDockerNetworkLabelTest.php +++ b/tests/Feature/TraefikServiceDockerNetworkLabelTest.php @@ -12,7 +12,7 @@ use Illuminate\Support\Facades\Bus; uses(RefreshDatabase::class); -it('selects the Coolify service network for Traefik routed compose services', function () { +it('does not force the private service network for Traefik routed compose services', function () { Bus::fake(); $team = Team::factory()->create(); @@ -45,5 +45,5 @@ YAML, $parsedCompose = serviceParser($service); $labels = collect(data_get($parsedCompose, 'services.app.labels')); - expect($labels->values()->all())->toContain("traefik.docker.network={$service->uuid}"); + expect($labels->values()->all())->not->toContain("traefik.docker.network={$service->uuid}"); }); diff --git a/tests/Unit/FqdnLabelsNoindexTest.php b/tests/Unit/FqdnLabelsNoindexTest.php index 48f2f49126..332d548989 100644 --- a/tests/Unit/FqdnLabelsNoindexTest.php +++ b/tests/Unit/FqdnLabelsNoindexTest.php @@ -17,13 +17,14 @@ function traefikLabels(array $domains, ?array $noindex = null, bool $forceHttps )->values()->all(); } -function caddyLabels(array $domains, ?array $noindex = null): array +function caddyLabels(array $domains, ?array $noindex = null, bool $forceHttps = false): array { return fqdnLabelsForCaddy( network: 'testnetwork', uuid: 'testuuid', domains: collect($domains), onlyPort: 3000, + is_force_https_enabled: $forceHttps, noindex_domains: $noindex === null ? null : collect($noindex), )->values()->all(); } @@ -43,6 +44,33 @@ function middlewaresOf(array $labels, string $router): array } describe('Traefik noindex middleware', function () { + test('the HTTP router inherits the HTTPS middleware chain when redirects are disabled', function () { + $labels = fqdnLabelsForTraefik( + uuid: 'testuuid', + domains: collect(['https://example.com/api']), + is_force_https_enabled: false, + onlyPort: 3000, + serviceLabels: collect(['coolify.traefik.middlewares=rate-limit']), + redirect_direction: 'www', + is_http_basic_auth_enabled: true, + http_basic_auth_username: 'user', + http_basic_auth_password: 'secret', + noindex_domains: collect(['https://example.com/api']), + )->values()->all(); + + expect(middlewaresOf($labels, 'http-0-testuuid')) + ->toBe(middlewaresOf($labels, 'https-0-testuuid')) + ->toContain( + 'https-0-testuuid-stripprefix', + 'gzip', + '0-testuuid-to-www', + 'http-basic-auth-testuuid', + '0-testuuid-noindex', + 'rate-limit', + ) + ->not->toContain('redirect-to-https'); + }); + test('only the flagged domain gets the header', function () { $labels = traefikLabels( domains: ['https://prod.example.com', 'https://staging.example.com'], @@ -138,6 +166,44 @@ describe('Traefik noindex middleware', function () { }); describe('Caddy noindex header', function () { + test('serves an HTTPS public domain over HTTP and HTTPS when the HTTPS redirect is disabled', function () { + expect(caddyLabels(['https://example.com'], forceHttps: false)) + ->toContain('caddy_0=http://example.com, https://example.com'); + }); + + test('serves an HTTPS public domain over HTTPS when the HTTPS redirect is enabled', function () { + expect(caddyLabels(['https://example.com'], forceHttps: true)) + ->toContain('caddy_0=https://example.com'); + }); + + test('canonical redirects preserve the request scheme when the HTTPS redirect is disabled', function (string $domain, string $redirectDirection, string $expectedRedirect, string $httpsRedirect) { + $labels = fqdnLabelsForCaddy( + network: 'testnetwork', + uuid: 'testuuid', + domains: collect([$domain]), + is_force_https_enabled: false, + onlyPort: 3000, + redirect_direction: $redirectDirection, + ); + + expect($labels) + ->toContain($expectedRedirect) + ->not->toContain($httpsRedirect); + })->with([ + 'www redirect' => [ + 'https://example.com', + 'www', + 'caddy_0.redir={scheme}://www.example.com{uri}', + 'caddy_0.redir=https://www.example.com{uri}', + ], + 'non-www redirect' => [ + 'https://www.example.com', + 'non-www', + 'caddy_0.redir={scheme}://example.com{uri}', + 'caddy_0.redir=https://example.com{uri}', + ], + ]); + test('the flagged domain uses the header block, the other keeps the inline form', function () { $labels = caddyLabels( domains: ['https://prod.example.com', 'https://staging.example.com'], diff --git a/tests/Unit/ServerTransfer/ServerTransferExporterImporterTest.php b/tests/Unit/ServerTransfer/ServerTransferExporterImporterTest.php index 28675903ba..5f940108c3 100644 --- a/tests/Unit/ServerTransfer/ServerTransferExporterImporterTest.php +++ b/tests/Unit/ServerTransfer/ServerTransferExporterImporterTest.php @@ -509,6 +509,7 @@ test('service nested apps dbs volumes backups and db file storages round-trip', 'is_log_drain_enabled' => true, 'is_gzip_enabled' => false, 'is_stripprefix_enabled' => false, + 'is_force_https_enabled' => false, 'status' => 'running:healthy', ]); $serviceApp->uuid = 'svc-app-whoami'; @@ -722,6 +723,7 @@ test('service nested apps dbs volumes backups and db file storages round-trip', ->and($importedSvcApp->exposes)->toBe('80') ->and((bool) $importedSvcApp->is_gzip_enabled)->toBeFalse() ->and((bool) $importedSvcApp->is_stripprefix_enabled)->toBeFalse() + ->and((bool) $importedSvcApp->is_force_https_enabled)->toBeFalse() ->and($importedSvcApp->environment_variables()->where('key', 'WHOAMI_NAME')->first()?->value)->toBe('nested-secret') ->and($importedSvcApp->persistentStorages)->toHaveCount(1) ->and($importedSvcApp->fileStorages)->toHaveCount(1) From 0d23b29775b0325801e9d9c0e0b768bea91ccc33 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Tue, 18 Aug 2026 09:16:29 +0200 Subject: [PATCH 20/42] fix: honor GitHub default branches and reconcile proxy networks Use searchable repository and branch selectors, select each repository's default branch when available, and discover running container networks during proxy reconciliation. --- .../Project/New/GithubPrivateRepository.php | 10 ++-- bootstrap/helpers/proxy.php | 23 ++++---- .../forms/searchable-listbox.blade.php | 2 +- .../new/github-private-repository.blade.php | 8 +-- tests/Feature/GithubPrivateRepositoryTest.php | 48 +++++++++++++++-- .../RuntimeNetworkReconciliationTest.php | 53 +++++++++++++++++++ .../SearchableListboxComponentTest.php | 1 + 7 files changed, 123 insertions(+), 22 deletions(-) create mode 100644 tests/Feature/Proxy/RuntimeNetworkReconciliationTest.php diff --git a/app/Livewire/Project/New/GithubPrivateRepository.php b/app/Livewire/Project/New/GithubPrivateRepository.php index 925f8d9698..cef0cf6ac7 100644 --- a/app/Livewire/Project/New/GithubPrivateRepository.php +++ b/app/Livewire/Project/New/GithubPrivateRepository.php @@ -134,8 +134,9 @@ class GithubPrivateRepository extends Component public function loadBranches() { - $this->selected_repository_owner = $this->repositories->where('id', $this->selected_repository_id)->first()['owner']['login']; - $this->selected_repository_repo = $this->repositories->where('id', $this->selected_repository_id)->first()['name']; + $repository = $this->repositories->firstWhere('id', $this->selected_repository_id); + $this->selected_repository_owner = data_get($repository, 'owner.login'); + $this->selected_repository_repo = data_get($repository, 'name'); $this->branches = collect(); $this->page = 1; $this->loadBranchByPage(); @@ -146,7 +147,10 @@ class GithubPrivateRepository extends Component } } $this->branches = sortBranchesByPriority($this->branches); - $this->selected_branch_name = data_get($this->branches, '0.name', 'main'); + $defaultBranch = data_get($repository, 'default_branch', 'main'); + $this->selected_branch_name = $this->branches->contains('name', $defaultBranch) + ? $defaultBranch + : data_get($this->branches, '0.name', 'main'); } protected function loadBranchByPage() diff --git a/bootstrap/helpers/proxy.php b/bootstrap/helpers/proxy.php index 6997043937..31e55da336 100644 --- a/bootstrap/helpers/proxy.php +++ b/bootstrap/helpers/proxy.php @@ -107,8 +107,8 @@ function collectDockerNetworksByServer(Server $server) } function connectProxyToNetworks(Server $server) { - ['networks' => $networks] = collectDockerNetworksByServer($server); if ($server->isSwarm()) { + ['networks' => $networks] = collectDockerNetworksByServer($server); $commands = $networks->map(function ($network) { $safe = escapeshellarg($network); @@ -118,19 +118,20 @@ function connectProxyToNetworks(Server $server) "echo 'Successfully connected coolify-proxy to {$safe} network.'", ]; }); - } else { - $commands = $networks->map(function ($network) { - $safe = escapeshellarg($network); - return [ - "docker network ls --format '{{.Name}}' | grep '^{$network}$' >/dev/null || docker network create --attachable {$safe} >/dev/null", - "docker network connect {$safe} coolify-proxy >/dev/null 2>&1 || true", - "echo 'Successfully connected coolify-proxy to {$safe} network.'", - ]; - }); + return $commands->flatten(); } - return $commands->flatten(); + return collect([ + 'for network in $(docker inspect $(docker ps --filter label=coolify.managed=true --format "{{.ID}}") --format=\'{{range $network, $_ := .NetworkSettings.Networks}}{{println $network}}{{end}}\' 2>/dev/null | sort -u); do', + ' if [ -z "$network" ] || [ "$network" = "bridge" ] || [ "$network" = "host" ] || [ "$network" = "none" ] || [ "$network" = "default" ]; then', + ' continue', + ' fi', + ' if docker network inspect "$network" >/dev/null 2>&1; then', + ' docker network connect "$network" coolify-proxy >/dev/null 2>&1 || true', + ' fi', + 'done', + ]); } /** diff --git a/resources/views/components/forms/searchable-listbox.blade.php b/resources/views/components/forms/searchable-listbox.blade.php index c75a50c0ba..9a7a88efd5 100644 --- a/resources/views/components/forms/searchable-listbox.blade.php +++ b/resources/views/components/forms/searchable-listbox.blade.php @@ -111,7 +111,7 @@ @click.stop>