From 99e97900a5c1e7c2d9c53c6c0ec0364753670363 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Mon, 10 Nov 2025 13:43:27 +0100 Subject: [PATCH] feat: add automated PORT environment variable detection and UI warnings Add detection system for PORT environment variable to help users configure applications correctly: - Add detectPortFromEnvironment() method to Application model to detect PORT env var - Add getDetectedPortInfoProperty() computed property in General Livewire component - Display contextual info banners in UI when PORT is detected: - Warning when PORT exists but ports_exposes is empty - Warning when PORT doesn't match ports_exposes configuration - Info message when PORT matches ports_exposes - Add deployment logging to warn about PORT/ports_exposes mismatches - Include comprehensive unit tests for port detection logic The ports_exposes field remains authoritative for proxy configuration, while PORT detection provides helpful suggestions to users. --- app/Jobs/ApplicationDeploymentJob.php | 9 + app/Livewire/Project/Application/General.php | 19 +++ app/Models/Application.php | 18 ++ .../project/application/general.blade.php | 33 ++++ templates/service-templates-latest.json | 16 +- templates/service-templates.json | 16 +- tests/Unit/ApplicationPortDetectionTest.php | 156 ++++++++++++++++++ 7 files changed, 251 insertions(+), 16 deletions(-) create mode 100644 tests/Unit/ApplicationPortDetectionTest.php diff --git a/app/Jobs/ApplicationDeploymentJob.php b/app/Jobs/ApplicationDeploymentJob.php index ea8cdff95..e7810bf99 100644 --- a/app/Jobs/ApplicationDeploymentJob.php +++ b/app/Jobs/ApplicationDeploymentJob.php @@ -1146,6 +1146,15 @@ private function generate_runtime_environment_variables() foreach ($runtime_environment_variables as $env) { $envs->push($env->key.'='.$env->real_value); } + + // Check for PORT environment variable mismatch with ports_exposes + if ($this->build_pack !== 'dockercompose') { + $detectedPort = $this->application->detectPortFromEnvironment(false); + if ($detectedPort && ! empty($ports) && ! in_array($detectedPort, $ports)) { + ray()->orange("PORT environment variable ({$detectedPort}) does not match configured ports_exposes: ".implode(',', $ports)); + } + } + // Add PORT if not exists, use the first port as default if ($this->build_pack !== 'dockercompose') { if ($this->application->environment_variables->where('key', 'PORT')->isEmpty()) { diff --git a/app/Livewire/Project/Application/General.php b/app/Livewire/Project/Application/General.php index a83e6f70a..ce7d6b1b7 100644 --- a/app/Livewire/Project/Application/General.php +++ b/app/Livewire/Project/Application/General.php @@ -1000,4 +1000,23 @@ private function updateServiceEnvironmentVariables() } } } + + public function getDetectedPortInfoProperty(): ?array + { + $detectedPort = $this->application->detectPortFromEnvironment(); + + if (! $detectedPort) { + return null; + } + + $portsExposesArray = $this->application->ports_exposes_array; + $isMatch = in_array($detectedPort, $portsExposesArray); + $isEmpty = empty($portsExposesArray); + + return [ + 'port' => $detectedPort, + 'matches' => $isMatch, + 'isEmpty' => $isEmpty, + ]; + } } diff --git a/app/Models/Application.php b/app/Models/Application.php index 615e35f68..f73bb562a 100644 --- a/app/Models/Application.php +++ b/app/Models/Application.php @@ -772,6 +772,24 @@ public function main_port() return $this->settings->is_static ? [80] : $this->ports_exposes_array; } + public function detectPortFromEnvironment(?bool $isPreview = false): ?int + { + $envVars = $isPreview + ? $this->environment_variables_preview + : $this->environment_variables; + + $portVar = $envVars->firstWhere('key', 'PORT'); + + if ($portVar && $portVar->real_value) { + $portValue = trim($portVar->real_value); + if (is_numeric($portValue)) { + return (int) $portValue; + } + } + + return null; + } + public function environment_variables() { return $this->morphMany(EnvironmentVariable::class, 'resourceable') diff --git a/resources/views/livewire/project/application/general.blade.php b/resources/views/livewire/project/application/general.blade.php index 8e614a4e9..c95260efe 100644 --- a/resources/views/livewire/project/application/general.blade.php +++ b/resources/views/livewire/project/application/general.blade.php @@ -369,6 +369,39 @@ class="underline" href="https://coolify.io/docs/knowledge-base/docker/registry" @endif @if ($application->build_pack !== 'dockercompose')

Network

+ @if ($this->detectedPortInfo) + @if ($this->detectedPortInfo['isEmpty']) +
+ + + +
+ PORT environment variable detected ({{ $this->detectedPortInfo['port'] }}) +

Your Ports Exposes field is empty. Consider setting it to {{ $this->detectedPortInfo['port'] }} to ensure the proxy routes traffic correctly.

+
+
+ @elseif (!$this->detectedPortInfo['matches']) +
+ + + +
+ PORT mismatch detected +

Your PORT environment variable is set to {{ $this->detectedPortInfo['port'] }}, but it's not in your Ports Exposes configuration. Ensure they match for proper proxy routing.

+
+
+ @else +
+ + + +
+ PORT environment variable configured +

Your PORT environment variable ({{ $this->detectedPortInfo['port'] }}) matches your Ports Exposes configuration.

+
+
+ @endif + @endif
@if ($application->settings->is_static || $application->build_pack === 'static') makePartial(); + + // Mock environment variables collection with PORT set to 3000 + $portEnvVar = Mockery::mock(EnvironmentVariable::class); + $portEnvVar->shouldReceive('getAttribute')->with('real_value')->andReturn('3000'); + + $envVars = new Collection([$portEnvVar]); + $application->shouldReceive('getAttribute') + ->with('environment_variables') + ->andReturn($envVars); + + // Mock the firstWhere method to return our PORT env var + $envVars = Mockery::mock(Collection::class); + $envVars->shouldReceive('firstWhere')->with('key', 'PORT')->andReturn($portEnvVar); + $application->shouldReceive('getAttribute') + ->with('environment_variables') + ->andReturn($envVars); + + // Call the method we're testing + $detectedPort = $application->detectPortFromEnvironment(); + + expect($detectedPort)->toBe(3000); +}); + +it('returns null when PORT environment variable is not set', function () { + $application = Mockery::mock(Application::class)->makePartial(); + + // Mock environment variables collection without PORT + $envVars = Mockery::mock(Collection::class); + $envVars->shouldReceive('firstWhere')->with('key', 'PORT')->andReturn(null); + $application->shouldReceive('getAttribute') + ->with('environment_variables') + ->andReturn($envVars); + + $detectedPort = $application->detectPortFromEnvironment(); + + expect($detectedPort)->toBeNull(); +}); + +it('returns null when PORT value is not numeric', function () { + $application = Mockery::mock(Application::class)->makePartial(); + + // Mock environment variables with non-numeric PORT value + $portEnvVar = Mockery::mock(EnvironmentVariable::class); + $portEnvVar->shouldReceive('getAttribute')->with('real_value')->andReturn('invalid-port'); + + $envVars = Mockery::mock(Collection::class); + $envVars->shouldReceive('firstWhere')->with('key', 'PORT')->andReturn($portEnvVar); + $application->shouldReceive('getAttribute') + ->with('environment_variables') + ->andReturn($envVars); + + $detectedPort = $application->detectPortFromEnvironment(); + + expect($detectedPort)->toBeNull(); +}); + +it('handles PORT value with whitespace', function () { + $application = Mockery::mock(Application::class)->makePartial(); + + // Mock environment variables with PORT value that has whitespace + $portEnvVar = Mockery::mock(EnvironmentVariable::class); + $portEnvVar->shouldReceive('getAttribute')->with('real_value')->andReturn(' 8080 '); + + $envVars = Mockery::mock(Collection::class); + $envVars->shouldReceive('firstWhere')->with('key', 'PORT')->andReturn($portEnvVar); + $application->shouldReceive('getAttribute') + ->with('environment_variables') + ->andReturn($envVars); + + $detectedPort = $application->detectPortFromEnvironment(); + + expect($detectedPort)->toBe(8080); +}); + +it('detects PORT from preview environment variables when isPreview is true', function () { + $application = Mockery::mock(Application::class)->makePartial(); + + // Mock preview environment variables with PORT + $portEnvVar = Mockery::mock(EnvironmentVariable::class); + $portEnvVar->shouldReceive('getAttribute')->with('real_value')->andReturn('4000'); + + $envVars = Mockery::mock(Collection::class); + $envVars->shouldReceive('firstWhere')->with('key', 'PORT')->andReturn($portEnvVar); + $application->shouldReceive('getAttribute') + ->with('environment_variables_preview') + ->andReturn($envVars); + + $detectedPort = $application->detectPortFromEnvironment(true); + + expect($detectedPort)->toBe(4000); +}); + +it('verifies ports_exposes array conversion logic', function () { + // Test the logic that converts comma-separated ports to array + $portsExposesString = '3000,3001,8080'; + $expectedArray = [3000, 3001, 8080]; + + // This simulates what portsExposesArray accessor does + $result = is_null($portsExposesString) + ? [] + : explode(',', $portsExposesString); + + // Convert to integers for comparison + $result = array_map('intval', $result); + + expect($result)->toBe($expectedArray); +}); + +it('verifies PORT matches detection logic', function () { + $detectedPort = 3000; + $portsExposesArray = [3000, 3001]; + + $isMatch = in_array($detectedPort, $portsExposesArray); + + expect($isMatch)->toBeTrue(); +}); + +it('verifies PORT mismatch detection logic', function () { + $detectedPort = 8080; + $portsExposesArray = [3000, 3001]; + + $isMatch = in_array($detectedPort, $portsExposesArray); + + expect($isMatch)->toBeFalse(); +}); + +it('verifies empty ports_exposes detection logic', function () { + $portsExposesArray = []; + + $isEmpty = empty($portsExposesArray); + + expect($isEmpty)->toBeTrue(); +});