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.
This commit is contained in:
Andras Bacsai 2025-11-10 13:43:27 +01:00
parent 775216e7a5
commit 99e97900a5
7 changed files with 251 additions and 16 deletions

View file

@ -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()) {

View file

@ -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,
];
}
}

View file

@ -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')

View file

@ -369,6 +369,39 @@ class="underline" href="https://coolify.io/docs/knowledge-base/docker/registry"
@endif
@if ($application->build_pack !== 'dockercompose')
<h3 class="pt-8">Network</h3>
@if ($this->detectedPortInfo)
@if ($this->detectedPortInfo['isEmpty'])
<div class="flex items-start gap-2 p-4 mb-4 text-sm rounded-lg bg-yellow-50 dark:bg-yellow-900/20 text-yellow-800 dark:text-yellow-300 border border-yellow-200 dark:border-yellow-800">
<svg class="w-5 h-5 shrink-0 mt-0.5" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M8.485 2.495c.673-1.167 2.357-1.167 3.03 0l6.28 10.875c.673 1.167-.17 2.625-1.516 2.625H3.72c-1.347 0-2.189-1.458-1.515-2.625L8.485 2.495zM10 5a.75.75 0 01.75.75v3.5a.75.75 0 01-1.5 0v-3.5A.75.75 0 0110 5zm0 9a1 1 0 100-2 1 1 0 000 2z" clip-rule="evenodd"/>
</svg>
<div>
<span class="font-semibold">PORT environment variable detected ({{ $this->detectedPortInfo['port'] }})</span>
<p class="mt-1">Your Ports Exposes field is empty. Consider setting it to <strong>{{ $this->detectedPortInfo['port'] }}</strong> to ensure the proxy routes traffic correctly.</p>
</div>
</div>
@elseif (!$this->detectedPortInfo['matches'])
<div class="flex items-start gap-2 p-4 mb-4 text-sm rounded-lg bg-yellow-50 dark:bg-yellow-900/20 text-yellow-800 dark:text-yellow-300 border border-yellow-200 dark:border-yellow-800">
<svg class="w-5 h-5 shrink-0 mt-0.5" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M8.485 2.495c.673-1.167 2.357-1.167 3.03 0l6.28 10.875c.673 1.167-.17 2.625-1.516 2.625H3.72c-1.347 0-2.189-1.458-1.515-2.625L8.485 2.495zM10 5a.75.75 0 01.75.75v3.5a.75.75 0 01-1.5 0v-3.5A.75.75 0 0110 5zm0 9a1 1 0 100-2 1 1 0 000 2z" clip-rule="evenodd"/>
</svg>
<div>
<span class="font-semibold">PORT mismatch detected</span>
<p class="mt-1">Your PORT environment variable is set to <strong>{{ $this->detectedPortInfo['port'] }}</strong>, but it's not in your Ports Exposes configuration. Ensure they match for proper proxy routing.</p>
</div>
</div>
@else
<div class="flex items-start gap-2 p-4 mb-4 text-sm rounded-lg bg-blue-50 dark:bg-blue-900/20 text-blue-800 dark:text-blue-300 border border-blue-200 dark:border-blue-800">
<svg class="w-5 h-5 shrink-0 mt-0.5" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a.75.75 0 000 1.5h.253a.25.25 0 01.244.304l-.459 2.066A1.75 1.75 0 0010.747 15H11a.75.75 0 000-1.5h-.253a.25.25 0 01-.244-.304l.459-2.066A1.75 1.75 0 009.253 9H9z" clip-rule="evenodd"/>
</svg>
<div>
<span class="font-semibold">PORT environment variable configured</span>
<p class="mt-1">Your PORT environment variable ({{ $this->detectedPortInfo['port'] }}) matches your Ports Exposes configuration.</p>
</div>
</div>
@endif
@endif
<div class="flex flex-col gap-2 xl:flex-row">
@if ($application->settings->is_static || $application->build_pack === 'static')
<x-forms.input id="portsExposes" label="Ports Exposes" readonly

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,156 @@
<?php
/**
* Unit tests for PORT environment variable detection feature.
*
* Tests verify that the Application model can correctly detect PORT environment
* variables and provide information to the UI about matches and mismatches with
* the configured ports_exposes field.
*/
use App\Models\Application;
use App\Models\EnvironmentVariable;
use Illuminate\Support\Collection;
use Mockery;
beforeEach(function () {
// Clean up Mockery after each test
Mockery::close();
});
it('detects PORT environment variable when present', function () {
// Create a mock Application instance
$application = Mockery::mock(Application::class)->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();
});