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 20b184a01..e46769a15 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 1fd315884..0ad69e28a 100644 --- a/tests/Unit/LocalFileVolumeContentSizeTest.php +++ b/tests/Unit/LocalFileVolumeContentSizeTest.php @@ -64,3 +64,16 @@ 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 159f12252..7df16c52b 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 @@ private function configMatchesProxyType(string $proxyType, string $configuration 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 dc11ec89e..02f3daa3b 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 @@ public function handle(): void 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 @@ public function handle(): void } } + 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 d0121bdc5..fa575073f 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 @@ public function getLogs($refresh = false) $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 @@ public function getLogs($refresh = false) $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 @@ public function getLogs($refresh = false) })->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 @@ public function downloadAllLogs(): string $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 @@ public function downloadAllLogs(): string 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 f824645aa..6351dace8 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 @@ public function loadDynamicConfigurations() 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 732142b0d..6f1102717 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 @@ public function loadComposeFile($isInit = false, ?string $restoreBaseDirectory = $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 @@ public function loadComposeFile($isInit = false, ?string $restoreBaseDirectory = '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 @@ public function loadComposeFile($isInit = false, ?string $restoreBaseDirectory = '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 @@ public function loadComposeFile($isInit = false, ?string $restoreBaseDirectory = } 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 000000000..e010970bb --- /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 02f3daa3b..d24f36350 100644 --- a/app/Jobs/ScheduledTaskJob.php +++ b/app/Jobs/ScheduledTaskJob.php @@ -150,11 +150,12 @@ public function handle(): void 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 fa575073f..67a040ef7 100644 --- a/app/Livewire/Project/Shared/GetLogs.php +++ b/app/Livewire/Project/Shared/GetLogs.php @@ -192,17 +192,17 @@ public function getLogs($refresh = false) 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 @@ public function downloadAllLogs(): string 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 e46769a15..05430930b 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 db75f7b75..c920b94ed 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\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 @@ $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 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 0ad69e28a..195453978 100644 --- a/tests/Unit/LocalFileVolumeContentSizeTest.php +++ b/tests/Unit/LocalFileVolumeContentSizeTest.php @@ -77,3 +77,14 @@ ->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 e010970bb..b8341dd73 100644 --- a/tests/Unit/RemoteOutputSizeLimitsTest.php +++ b/tests/Unit/RemoteOutputSizeLimitsTest.php @@ -5,6 +5,7 @@ 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 @@ function remoteOutputSource(string $path): string ->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 d24f36350..f7bd5f933 100644 --- a/app/Jobs/ScheduledTaskJob.php +++ b/app/Jobs/ScheduledTaskJob.php @@ -211,8 +211,9 @@ public function handle(): void 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 968e6c3d0..6b0124cb9 100644 --- a/app/Models/LocalFileVolume.php +++ b/app/Models/LocalFileVolume.php @@ -113,9 +113,9 @@ public function loadStorageOnServer() 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 @@ protected function remoteFileExceedsLimit(string $escapedPath, $server): bool 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 @@ public function saveStorageOnServer() 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 195453978..1894bf439 100644 --- a/tests/Unit/LocalFileVolumeContentSizeTest.php +++ b/tests/Unit/LocalFileVolumeContentSizeTest.php @@ -88,3 +88,38 @@ '/\$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 b8341dd73..3660e8828 100644 --- a/tests/Unit/RemoteOutputSizeLimitsTest.php +++ b/tests/Unit/RemoteOutputSizeLimitsTest.php @@ -58,7 +58,7 @@ function remoteOutputSource(string $path): string 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 @@ function remoteOutputSource(string $path): string 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 @@ function remoteOutputSource(string $path): string ->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 f44008085..c4f98cfdf 100644 --- a/app/Actions/Database/StartMariadb.php +++ b/app/Actions/Database/StartMariadb.php @@ -211,7 +211,7 @@ public function handle(StandaloneMariadb $database) $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 b31251ce2..0f9bba679 100644 --- a/app/Actions/Database/StartMongodb.php +++ b/app/Actions/Database/StartMongodb.php @@ -260,7 +260,7 @@ public function handle(StandaloneMongodb $database) $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 16b8e1585..3eab00464 100644 --- a/app/Actions/Database/StartMysql.php +++ b/app/Actions/Database/StartMysql.php @@ -212,7 +212,7 @@ public function handle(StandaloneMysql $database) $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 3b0f820df..21cb278fb 100644 --- a/app/Actions/Database/StartPostgresql.php +++ b/app/Actions/Database/StartPostgresql.php @@ -222,7 +222,7 @@ public function handle(StandalonePostgresql $database) $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 1e6395df8..76e34334c 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 e32d035c5..2f449eed8 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 e236bff30..6a0472191 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 684234518..28f07a9a2 100644 --- a/tests/Feature/StandardTableComponentsTest.php +++ b/tests/Feature/StandardTableComponentsTest.php @@ -24,8 +24,8 @@ ->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 f6e5f5a81..d26a1a33d 100644 --- a/tests/Feature/TablePaginationLoadingTest.php +++ b/tests/Feature/TablePaginationLoadingTest.php @@ -30,6 +30,20 @@ ->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 c7d1229d2..754eca1c3 100644 --- a/tests/Unit/ProductionImageWorkflowTest.php +++ b/tests/Unit/ProductionImageWorkflowTest.php @@ -23,9 +23,9 @@ ->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 e32d035c5..2f449eed8 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 e06516188..04fe00ad4 100644 --- a/app/Actions/Server/CleanupDocker.php +++ b/app/Actions/Server/CleanupDocker.php @@ -131,7 +131,7 @@ private function buildImagePruneCommand( $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 9ddd5e740..477abc08e 100644 --- a/app/Livewire/Project/Application/Domains.php +++ b/app/Livewire/Project/Application/Domains.php @@ -6,6 +6,7 @@ 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 @@ public function updatedNewDomain(): void $this->resetAddDomainDnsGate(); } + public function updatedNewDomainParts(): void + { + $this->newDomainPartsChanged = true; + $this->resetAddDomainDnsGate(); + } + public function updatedNewDomainService(): void { $this->resetAddDomainDnsGate(); @@ -677,6 +692,8 @@ public function resetAddDomainDnsGate(): void public function resetAddDomainForm(): void { $this->newDomain = ''; + $this->newDomainParts = DomainUrlParts::empty(); + $this->newDomainPartsChanged = false; $this->resetAddDomainDnsGate(); $this->resetErrorBag('newDomain'); } @@ -743,6 +760,9 @@ public function addDomain(): void return; } + if ($this->newDomainPartsChanged) { + $this->newDomain = DomainUrlParts::compose(...$this->newDomainParts); + } $this->validateOnly('newDomain'); $normalized = ValidationPatterns::normalizeApplicationDomains($this->newDomain); @@ -893,6 +913,12 @@ public function updatedEditingDomain(): void $this->resetEditDomainDnsGate(); } + public function updatedEditingDomainParts(): void + { + $this->editingDomainPartsChanged = true; + $this->resetEditDomainDnsGate(); + } + public function resetEditDomainDnsGate(): void { $this->editDomainDnsFailed = false; @@ -908,10 +934,13 @@ public function startEdit(int $index): void $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 @@ public function cancelEdit(): void $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 @@ public function updateDomain(): void 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 a5479ca06..ad31560b5 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 @@ public function updatedNewDomain(): void $this->forceSaveDns = false; } + public function updatedNewDomainParts(): void + { + $this->newDomainPartsChanged = true; + $this->resetAddDomainDnsGate(); + } + public function updatedEditingDomain(): void { $this->editDomainDnsFailed = false; @@ -522,6 +536,12 @@ public function updatedEditingDomain(): void $this->forceSaveEditDns = false; } + public function updatedEditingDomainParts(): void + { + $this->editingDomainPartsChanged = true; + $this->updatedEditingDomain(); + } + public function confirmAddDomainDespiteDns(): void { $this->forceSaveDns = true; @@ -842,6 +862,9 @@ public function addDomain(): void { 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 @@ public function addDomain(): void } $this->newDomain = ''; + $this->newDomainParts = DomainUrlParts::empty(); + $this->newDomainPartsChanged = false; $this->addDomainDnsFailed = false; $this->addDomainDnsMessage = ''; $this->forceSaveDns = false; @@ -916,12 +941,15 @@ public function startEdit(int $index): void $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 @@ public function cancelEdit(): void $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 @@ public function updateDomain(): void return; } + if ($this->editingDomainPartsChanged) { + $this->editingDomain = DomainUrlParts::compose(...$this->editingDomainParts); + } $this->validateOnly('editingDomain'); $app = $this->findServiceApp($this->editingServiceApplicationId); @@ -1130,6 +1163,8 @@ public function generateDomain(): void } $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 d741e6918..9e22e5491 100644 --- a/app/Livewire/Storage/Create.php +++ b/app/Livewire/Storage/Create.php @@ -5,6 +5,7 @@ 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 @@ public function submit() 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 @@ public function submit() } } + 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 94a0657ef..30084dcd3 100644 --- a/app/Livewire/Storage/Form.php +++ b/app/Livewire/Storage/Form.php @@ -5,6 +5,7 @@ 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 @@ private function syncData(bool $toModel = false): void $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 @@ public function testConnection() 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 @@ public function submit() { try { $this->authorize('update', $this->storage); + if ($this->endpointPartsChanged) { + $this->endpoint = DomainUrlParts::compose(...$this->endpointParts); + } DB::transaction(function () { $this->validate(); @@ -195,4 +208,9 @@ public function submit() 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 d6e6bce85..5a4b5f950 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 bfcb4437e..4677911d9 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 fb5bf9b1d..04dd249c7 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 @@ class="button button-highlighted"> :disabled="! auth()->user()->can('update', $application)" /> @endif - + @if ($addDomainDnsFailed) @@ -320,7 +304,7 @@ class="icon-button shrink-0" aria-label="Close">
-
+
@@ -328,8 +312,7 @@ class="icon-button shrink-0" aria-label="Close">
- + @if ($editDomainDnsFailed) @@ -345,7 +328,7 @@ class="icon-button shrink-0" aria-label="Close">
@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 d5baaee56..10aa21200 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 @@ class="icon-button shrink-0" title="Check DNS" aria-label="Check DNS"> @endif @else - diff --git a/resources/views/livewire/storage/create.blade.php b/resources/views/livewire/storage/create.blade.php index 1154a7e90..c0d6a6e27 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 2a76e7cc6..d13d632fb 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 288952a7d..fbe72230d 100644 --- a/tests/Feature/ApplicationDomainsTest.php +++ b/tests/Feature/ApplicationDomainsTest.php @@ -236,6 +236,22 @@ ->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 @@ $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 1773f37f1..226b1cc5e 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 a909d2e49..516a9d7eb 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 000000000..59d88e3f0 --- /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 e5abb2ef5..cf313882f 100644 --- a/tests/Feature/ProfileAvatarTest.php +++ b/tests/Feature/ProfileAvatarTest.php @@ -19,7 +19,7 @@ }); 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 @@ ->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 @@ 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('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 @@ ->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 3599c0b4b..b5b6ed088 100644 --- a/tests/Feature/ProjectIconTest.php +++ b/tests/Feature/ProjectIconTest.php @@ -31,7 +31,7 @@ }); 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 @@ ->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('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 6466a33ed..29f611cba 100644 --- a/tests/Feature/ResourceDetailsVisibilityTest.php +++ b/tests/Feature/ResourceDetailsVisibilityTest.php @@ -38,15 +38,22 @@ 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 86fc0f00b..5563a18ec 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -179,6 +179,7 @@ ## Key Conventions - 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 b8ef84687..397efc387 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 f294de35f..b741b875f 100644 --- a/tests/Unit/ParseCommandsByLineForSudoTest.php +++ b/tests/Unit/ParseCommandsByLineForSudoTest.php @@ -24,6 +24,24 @@ 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 74e2ff615..803eef398 100644 --- a/app/Actions/Application/CleanupPreviewDeployment.php +++ b/app/Actions/Application/CleanupPreviewDeployment.php @@ -54,6 +54,14 @@ public function handle( $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 @@ private function cancelActiveDeployments( $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 396844cb0..a0f0cc1ae 100644 --- a/app/Http/Controllers/Api/DeployController.php +++ b/app/Http/Controllers/Api/DeployController.php @@ -238,57 +238,71 @@ public function cancel_deployment(Request $request) 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 @@ public function cancel_deployment(Request $request) 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 436bf788b..124cc16cc 100644 --- a/app/Jobs/DeleteResourceJob.php +++ b/app/Jobs/DeleteResourceJob.php @@ -158,12 +158,15 @@ private function deleteApplicationPreview() ]) ->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 @@ private function deleteApplicationPreview() } } + 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 bbec09126..1133f3429 100644 --- a/app/Mcp/Tools/CancelDeployment.php +++ b/app/Mcp/Tools/CancelDeployment.php @@ -104,6 +104,13 @@ public function handle(Request $request): Response '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 046af059a..81087a68d 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 @@ }); 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 000000000..304839808 --- /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 b755e75b9..c3433bf45 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 @@ function mcpReadJson($response): array Process::fake([ '*' => Process::result(output: ''), ]); + Queue::fake(); $deployment = ApplicationDeploymentQueue::create([ 'application_id' => $this->application->id, @@ -1755,6 +1765,17 @@ function mcpReadJson($response): array '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 @@ function mcpReadJson($response): array 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 68115e775..f26d81b88 100644 --- a/app/Livewire/Project/Application/Backup/Create.php +++ b/app/Livewire/Project/Application/Backup/Create.php @@ -82,7 +82,7 @@ public function mount(): void '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 88e679a7c..496894591 100644 --- a/tests/Feature/VolumeBackupTest.php +++ b/tests/Feature/VolumeBackupTest.php @@ -222,6 +222,18 @@ 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 82e35b73c..1838feb9e 100644 --- a/app/Jobs/DatabaseBackupJob.php +++ b/app/Jobs/DatabaseBackupJob.php @@ -279,33 +279,10 @@ public function handle(): void } 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 @@ private function backup_standalone_mongodb(string $databaseWithCollections): voi } } + /** @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 41fb1681b..73877a945 100644 --- a/app/Livewire/Project/Database/BackupExecutions.php +++ b/app/Livewire/Project/Database/BackupExecutions.php @@ -98,26 +98,34 @@ public function deleteBackup($executionId, $password, $selectedActions = []) 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 4038c6288..e41c793c8 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 7e47126ab..23e1bbd8d 100644 --- a/tests/Feature/Jobs/DatabaseBackupJobTest.php +++ b/tests/Feature/Jobs/DatabaseBackupJobTest.php @@ -44,6 +44,14 @@ 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 @@ ->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 76e34334c..bc2151467 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 2f449eed8..ebc1151c3 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 754eca1c3..e57587f28 100644 --- a/tests/Unit/ProductionImageWorkflowTest.php +++ b/tests/Unit/ProductionImageWorkflowTest.php @@ -23,9 +23,9 @@ ->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 2f449eed8..ebc1151c3 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 3be2ae008..d4466d389 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 95e0207ce..566f29031 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -1903,6 +1903,10 @@ .dark .listbox-trigger { 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 a64a97dd8..a35ff8cbe 100644 --- a/tests/Feature/ListboxTriggerTruncationTest.php +++ b/tests/Feature/ListboxTriggerTruncationTest.php @@ -22,6 +22,13 @@ ->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 235071dbe..28c9a00c6 100644 --- a/app/Livewire/DeploymentsIndicator.php +++ b/app/Livewire/DeploymentsIndicator.php @@ -54,12 +54,6 @@ public function deploymentCount() 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 566f29031..95e0207ce 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -1903,10 +1903,6 @@ .dark .listbox-trigger { 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 ece7e4b44..5b9e41f76 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 @@ class="absolute bottom-full mb-2 w-[min(22rem,calc(100vw-2rem))] overflow-hidden @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 @@ class="shrink-0" /> {{-- Collapsed pill --}}
diff --git a/resources/views/components/services/links.blade.php b/resources/views/components/services/links.blade.php index 5d7884628..32de3af61 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 fbe72230d..50c71fe95 100644 --- a/tests/Feature/ApplicationDomainsTest.php +++ b/tests/Feature/ApplicationDomainsTest.php @@ -119,6 +119,27 @@ 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 270927ca1..1b3e61be0 100644 --- a/tests/Feature/ApplicationParserDockerComposeDomainsTest.php +++ b/tests/Feature/ApplicationParserDockerComposeDomainsTest.php @@ -399,7 +399,7 @@ 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 @@ $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 4f1dee08c..f0dd97cc3 100644 --- a/tests/Feature/Auth/TwoFactorChallengeAccessTest.php +++ b/tests/Feature/Auth/TwoFactorChallengeAccessTest.php @@ -23,6 +23,18 @@ 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 a64a97dd8..d5f259a85 100644 --- a/tests/Feature/ListboxTriggerTruncationTest.php +++ b/tests/Feature/ListboxTriggerTruncationTest.php @@ -114,7 +114,8 @@ ->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 240f5b2e1..01ba0ecf1 100644 --- a/tests/Feature/Mcp/McpEndpointTest.php +++ b/tests/Feature/Mcp/McpEndpointTest.php @@ -216,6 +216,13 @@ function expectMcpAuditLog(array $expected): void 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 @@ function expectMcpAuditLog(array $expected): void $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 c3433bf45..6e146e719 100644 --- a/tests/Feature/Mcp/McpReadToolsTest.php +++ b/tests/Feature/Mcp/McpReadToolsTest.php @@ -590,6 +590,51 @@ function mcpReadJson($response): array 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 50b6e02a9..46d9c36da 100644 --- a/tests/Feature/ModelFillableCreationTest.php +++ b/tests/Feature/ModelFillableCreationTest.php @@ -463,6 +463,7 @@ '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 @@ 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 81e596ebf..bb9e88898 100644 --- a/tests/Feature/ResourceHeadingUnifiedNavbarTest.php +++ b/tests/Feature/ResourceHeadingUnifiedNavbarTest.php @@ -635,20 +635,31 @@ ->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 d65967bd8..c8dd33a46 100644 --- a/tests/Feature/ServiceApplicationsApiTest.php +++ b/tests/Feature/ServiceApplicationsApiTest.php @@ -199,6 +199,20 @@ function createServiceWithoutApplicationsForApiTest(object $ctx): Service 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 36f602856..62ea41d96 100644 --- a/tests/Feature/ServiceDomainsTest.php +++ b/tests/Feature/ServiceDomainsTest.php @@ -125,6 +125,24 @@ ->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 @@ ->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 4cf4ebc56..f1d7087a2 100644 --- a/tests/Feature/TraefikServiceDockerNetworkLabelTest.php +++ b/tests/Feature/TraefikServiceDockerNetworkLabelTest.php @@ -12,7 +12,7 @@ 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 @@ $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 48f2f4912..332d54898 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 @@ function middlewaresOf(array $labels, string $router): array }); 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 28675903b..5f940108c 100644 --- a/tests/Unit/ServerTransfer/ServerTransferExporterImporterTest.php +++ b/tests/Unit/ServerTransfer/ServerTransferExporterImporterTest.php @@ -509,6 +509,7 @@ '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 @@ ->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 925f8d969..cef0cf6ac 100644 --- a/app/Livewire/Project/New/GithubPrivateRepository.php +++ b/app/Livewire/Project/New/GithubPrivateRepository.php @@ -134,8 +134,9 @@ public function loadRepositories(int $github_app_id): void 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 @@ public function loadBranches() } } $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 699704393..31e55da33 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 c75a50c0b..9a7a88efd 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>