feat: add comprehensive environment variable parsing with nested resolution and hardcoded variable detection

This commit introduces advanced environment variable handling capabilities including:
- Nested environment variable resolution with circular dependency detection
- Extraction of hardcoded environment variables from docker-compose.yml
- New ShowHardcoded Livewire component for displaying detected variables
- Enhanced UI for better environment variable management

The changes improve the user experience by automatically detecting and displaying
environment variables that are hardcoded in docker-compose files, allowing users
to override them if needed. The nested variable resolution ensures complex variable
dependencies are properly handled.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Andras Bacsai 2025-11-25 15:22:38 +01:00
parent d67fcd1dff
commit 208f0eac99
10 changed files with 1145 additions and 87 deletions

View file

@ -79,6 +79,62 @@ public function getEnvironmentVariablesPreviewProperty()
return $this->resource->environment_variables_preview;
}
public function getHardcodedEnvironmentVariablesProperty()
{
return $this->getHardcodedVariables(false);
}
public function getHardcodedEnvironmentVariablesPreviewProperty()
{
return $this->getHardcodedVariables(true);
}
protected function getHardcodedVariables(bool $isPreview)
{
// Only for services and docker-compose applications
if ($this->resource->type() !== 'service' &&
($this->resourceClass !== 'App\Models\Application' ||
($this->resourceClass === 'App\Models\Application' && $this->resource->build_pack !== 'dockercompose'))) {
return collect([]);
}
$dockerComposeRaw = $this->resource->docker_compose_raw ?? $this->resource->docker_compose;
if (blank($dockerComposeRaw)) {
return collect([]);
}
// Extract all hard-coded variables
$hardcodedVars = extractHardcodedEnvironmentVariables($dockerComposeRaw);
// Filter out magic variables (SERVICE_FQDN_*, SERVICE_URL_*, SERVICE_NAME_*)
$hardcodedVars = $hardcodedVars->filter(function ($var) {
$key = $var['key'];
return ! str($key)->startsWith(['SERVICE_FQDN_', 'SERVICE_URL_', 'SERVICE_NAME_']);
});
// Filter out variables that exist in database (user has overridden/managed them)
// For preview, check against preview variables; for production, check against production variables
if ($isPreview) {
$managedKeys = $this->resource->environment_variables_preview()->pluck('key')->toArray();
} else {
$managedKeys = $this->resource->environment_variables()->where('is_preview', false)->pluck('key')->toArray();
}
$hardcodedVars = $hardcodedVars->filter(function ($var) use ($managedKeys) {
return ! in_array($var['key'], $managedKeys);
});
// Apply sorting based on is_env_sorting_enabled
if ($this->is_env_sorting_enabled) {
$hardcodedVars = $hardcodedVars->sortBy('key')->values();
}
// Otherwise keep order from docker-compose file
return $hardcodedVars;
}
public function getDevView()
{
$this->variables = $this->formatEnvironmentVariables($this->environmentVariables);

View file

@ -0,0 +1,31 @@
<?php
namespace App\Livewire\Project\Shared\EnvironmentVariable;
use Livewire\Component;
class ShowHardcoded extends Component
{
public array $env;
public string $key;
public ?string $value = null;
public ?string $comment = null;
public ?string $serviceName = null;
public function mount()
{
$this->key = $this->env['key'];
$this->value = $this->env['value'] ?? null;
$this->comment = $this->env['comment'] ?? null;
$this->serviceName = $this->env['service_name'] ?? null;
}
public function render()
{
return view('livewire.project.shared.environment-variable.show-hardcoded');
}
}

View file

@ -998,53 +998,139 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
} else {
if ($value->startsWith('$')) {
$isRequired = false;
if ($value->contains(':-')) {
$value = replaceVariables($value);
$key = $value->before(':');
$value = $value->after(':-');
} elseif ($value->contains('-')) {
$value = replaceVariables($value);
$key = $value->before('-');
$value = $value->after('-');
} elseif ($value->contains(':?')) {
$value = replaceVariables($value);
// Extract variable content between ${...} using balanced brace matching
$result = extractBalancedBraceContent($value->value(), 0);
$key = $value->before(':');
$value = $value->after(':?');
$isRequired = true;
} elseif ($value->contains('?')) {
$value = replaceVariables($value);
if ($result !== null) {
$content = $result['content'];
$split = splitOnOperatorOutsideNested($content);
$key = $value->before('?');
$value = $value->after('?');
$isRequired = true;
}
if ($originalValue->value() === $value->value()) {
// This means the variable does not have a default value, so it needs to be created in Coolify
$parsedKeyValue = replaceVariables($value);
if ($split !== null) {
// Has default value syntax (:-, -, :?, or ?)
$varName = $split['variable'];
$operator = $split['operator'];
$defaultValue = $split['default'];
$isRequired = str_contains($operator, '?');
// Create the primary variable with its default (only if it doesn't exist)
$envVar = $resource->environment_variables()->firstOrCreate([
'key' => $varName,
'resourceable_type' => get_class($resource),
'resourceable_id' => $resource->id,
], [
'value' => $defaultValue,
'is_preview' => false,
'is_required' => $isRequired,
]);
// Add the variable to the environment so it will be shown in the deployable compose file
$environment[$varName] = $envVar->value;
// Recursively process nested variables in default value
if (str_contains($defaultValue, '${')) {
$searchPos = 0;
$nestedResult = extractBalancedBraceContent($defaultValue, $searchPos);
while ($nestedResult !== null) {
$nestedContent = $nestedResult['content'];
$nestedSplit = splitOnOperatorOutsideNested($nestedContent);
// Determine the nested variable name
$nestedVarName = $nestedSplit !== null ? $nestedSplit['variable'] : $nestedContent;
// Skip SERVICE_URL_* and SERVICE_FQDN_* variables - they are handled by magic variable system
$isMagicVariable = str_starts_with($nestedVarName, 'SERVICE_URL_') || str_starts_with($nestedVarName, 'SERVICE_FQDN_');
if (! $isMagicVariable) {
if ($nestedSplit !== null) {
$nestedEnvVar = $resource->environment_variables()->firstOrCreate([
'key' => $nestedSplit['variable'],
'resourceable_type' => get_class($resource),
'resourceable_id' => $resource->id,
], [
'value' => $nestedSplit['default'],
'is_preview' => false,
]);
$environment[$nestedSplit['variable']] = $nestedEnvVar->value;
} else {
$nestedEnvVar = $resource->environment_variables()->firstOrCreate([
'key' => $nestedContent,
'resourceable_type' => get_class($resource),
'resourceable_id' => $resource->id,
], [
'is_preview' => false,
]);
$environment[$nestedContent] = $nestedEnvVar->value;
}
}
$searchPos = $nestedResult['end'] + 1;
if ($searchPos >= strlen($defaultValue)) {
break;
}
$nestedResult = extractBalancedBraceContent($defaultValue, $searchPos);
}
}
} else {
// Simple variable reference without default
$parsedKeyValue = replaceVariables($value);
$resource->environment_variables()->firstOrCreate([
'key' => $content,
'resourceable_type' => get_class($resource),
'resourceable_id' => $resource->id,
], [
'is_preview' => false,
'is_required' => $isRequired,
]);
// Add the variable to the environment
$environment[$content] = $value;
}
} else {
// Fallback to old behavior for malformed input (backward compatibility)
if ($value->contains(':-')) {
$value = replaceVariables($value);
$key = $value->before(':');
$value = $value->after(':-');
} elseif ($value->contains('-')) {
$value = replaceVariables($value);
$key = $value->before('-');
$value = $value->after('-');
} elseif ($value->contains(':?')) {
$value = replaceVariables($value);
$key = $value->before(':');
$value = $value->after(':?');
$isRequired = true;
} elseif ($value->contains('?')) {
$value = replaceVariables($value);
$key = $value->before('?');
$value = $value->after('?');
$isRequired = true;
}
if ($originalValue->value() === $value->value()) {
// This means the variable does not have a default value
$parsedKeyValue = replaceVariables($value);
$resource->environment_variables()->firstOrCreate([
'key' => $parsedKeyValue,
'resourceable_type' => get_class($resource),
'resourceable_id' => $resource->id,
], [
'is_preview' => false,
'is_required' => $isRequired,
]);
$environment[$parsedKeyValue->value()] = $value;
continue;
}
$resource->environment_variables()->firstOrCreate([
'key' => $parsedKeyValue,
'key' => $key,
'resourceable_type' => get_class($resource),
'resourceable_id' => $resource->id,
], [
'value' => $value,
'is_preview' => false,
'is_required' => $isRequired,
]);
// Add the variable to the environment so it will be shown in the deployable compose file
$environment[$parsedKeyValue->value()] = $value;
continue;
}
$resource->environment_variables()->firstOrCreate([
'key' => $key,
'resourceable_type' => get_class($resource),
'resourceable_id' => $resource->id,
], [
'value' => $value,
'is_preview' => false,
'is_required' => $isRequired,
]);
}
}
}
@ -1774,6 +1860,7 @@ function serviceParser(Service $resource): Collection
$serviceExists->fqdn = $url;
$serviceExists->save();
}
// Create FQDN variable
$resource->environment_variables()->updateOrCreate([
'key' => $key->value(),
'resourceable_type' => get_class($resource),
@ -1784,9 +1871,22 @@ function serviceParser(Service $resource): Collection
'comment' => $envComments[$originalMagicKey] ?? null,
]);
// Also create the paired SERVICE_URL_* variable
$urlKey = 'SERVICE_URL_'.strtoupper($fqdnFor);
$resource->environment_variables()->updateOrCreate([
'key' => $urlKey,
'resourceable_type' => get_class($resource),
'resourceable_id' => $resource->id,
], [
'value' => $url,
'is_preview' => false,
'comment' => $envComments[$urlKey] ?? null,
]);
} elseif ($command->value() === 'URL') {
$urlFor = $key->after('SERVICE_URL_')->lower()->value();
$url = generateUrl(server: $server, random: str($urlFor)->replace('_', '-')->value()."-$uuid");
$fqdn = generateFqdn(server: $server, random: str($urlFor)->replace('_', '-')->value()."-$uuid", parserVersion: $resource->compose_parsing_version);
$envExists = $resource->environment_variables()->where('key', $key->value())->first();
// Also check if a port-suffixed version exists (e.g., SERVICE_URL_DASHBOARD_6791)
@ -1803,6 +1903,7 @@ function serviceParser(Service $resource): Collection
$serviceExists->fqdn = $url;
$serviceExists->save();
}
// Create URL variable
$resource->environment_variables()->updateOrCreate([
'key' => $key->value(),
'resourceable_type' => get_class($resource),
@ -1813,6 +1914,18 @@ function serviceParser(Service $resource): Collection
'comment' => $envComments[$originalMagicKey] ?? null,
]);
// Also create the paired SERVICE_FQDN_* variable
$fqdnKey = 'SERVICE_FQDN_'.strtoupper($urlFor);
$resource->environment_variables()->updateOrCreate([
'key' => $fqdnKey,
'resourceable_type' => get_class($resource),
'resourceable_id' => $resource->id,
], [
'value' => $fqdn,
'is_preview' => false,
'comment' => $envComments[$fqdnKey] ?? null,
]);
} else {
$value = generateEnvValue($command, $resource);
$resource->environment_variables()->updateOrCreate([
@ -2213,55 +2326,149 @@ function serviceParser(Service $resource): Collection
} else {
if ($value->startsWith('$')) {
$isRequired = false;
if ($value->contains(':-')) {
$value = replaceVariables($value);
$key = $value->before(':');
$value = $value->after(':-');
} elseif ($value->contains('-')) {
$value = replaceVariables($value);
$key = $value->before('-');
$value = $value->after('-');
} elseif ($value->contains(':?')) {
$value = replaceVariables($value);
// Extract variable content between ${...} using balanced brace matching
$result = extractBalancedBraceContent($value->value(), 0);
$key = $value->before(':');
$value = $value->after(':?');
$isRequired = true;
} elseif ($value->contains('?')) {
$value = replaceVariables($value);
if ($result !== null) {
$content = $result['content'];
$split = splitOnOperatorOutsideNested($content);
$key = $value->before('?');
$value = $value->after('?');
$isRequired = true;
}
if ($originalValue->value() === $value->value()) {
// This means the variable does not have a default value, so it needs to be created in Coolify
$parsedKeyValue = replaceVariables($value);
if ($split !== null) {
// Has default value syntax (:-, -, :?, or ?)
$varName = $split['variable'];
$operator = $split['operator'];
$defaultValue = $split['default'];
$isRequired = str_contains($operator, '?');
// Create the primary variable with its default (only if it doesn't exist)
// Use firstOrCreate instead of updateOrCreate to avoid overwriting user edits
$envVar = $resource->environment_variables()->firstOrCreate([
'key' => $varName,
'resourceable_type' => get_class($resource),
'resourceable_id' => $resource->id,
], [
'value' => $defaultValue,
'is_preview' => false,
'is_required' => $isRequired,
'comment' => $envComments[$originalKey] ?? null,
]);
// Add the variable to the environment so it will be shown in the deployable compose file
$environment[$varName] = $envVar->value;
// Recursively process nested variables in default value
if (str_contains($defaultValue, '${')) {
// Extract and create nested variables
$searchPos = 0;
$nestedResult = extractBalancedBraceContent($defaultValue, $searchPos);
while ($nestedResult !== null) {
$nestedContent = $nestedResult['content'];
$nestedSplit = splitOnOperatorOutsideNested($nestedContent);
// Determine the nested variable name
$nestedVarName = $nestedSplit !== null ? $nestedSplit['variable'] : $nestedContent;
// Skip SERVICE_URL_* and SERVICE_FQDN_* variables - they are handled by magic variable system
$isMagicVariable = str_starts_with($nestedVarName, 'SERVICE_URL_') || str_starts_with($nestedVarName, 'SERVICE_FQDN_');
if (! $isMagicVariable) {
if ($nestedSplit !== null) {
// Create nested variable with its default (only if it doesn't exist)
$nestedEnvVar = $resource->environment_variables()->firstOrCreate([
'key' => $nestedSplit['variable'],
'resourceable_type' => get_class($resource),
'resourceable_id' => $resource->id,
], [
'value' => $nestedSplit['default'],
'is_preview' => false,
]);
// Add nested variable to environment
$environment[$nestedSplit['variable']] = $nestedEnvVar->value;
} else {
// Simple nested variable without default (only if it doesn't exist)
$nestedEnvVar = $resource->environment_variables()->firstOrCreate([
'key' => $nestedContent,
'resourceable_type' => get_class($resource),
'resourceable_id' => $resource->id,
], [
'is_preview' => false,
]);
// Add nested variable to environment
$environment[$nestedContent] = $nestedEnvVar->value;
}
}
// Look for more nested variables
$searchPos = $nestedResult['end'] + 1;
if ($searchPos >= strlen($defaultValue)) {
break;
}
$nestedResult = extractBalancedBraceContent($defaultValue, $searchPos);
}
}
} else {
// Simple variable reference without default
$resource->environment_variables()->updateOrCreate([
'key' => $content,
'resourceable_type' => get_class($resource),
'resourceable_id' => $resource->id,
], [
'is_preview' => false,
'is_required' => $isRequired,
'comment' => $envComments[$originalKey] ?? null,
]);
}
} else {
// Fallback to old behavior for malformed input (backward compatibility)
if ($value->contains(':-')) {
$value = replaceVariables($value);
$key = $value->before(':');
$value = $value->after(':-');
} elseif ($value->contains('-')) {
$value = replaceVariables($value);
$key = $value->before('-');
$value = $value->after('-');
} elseif ($value->contains(':?')) {
$value = replaceVariables($value);
$key = $value->before(':');
$value = $value->after(':?');
$isRequired = true;
} elseif ($value->contains('?')) {
$value = replaceVariables($value);
$key = $value->before('?');
$value = $value->after('?');
$isRequired = true;
}
if ($originalValue->value() === $value->value()) {
// This means the variable does not have a default value
$parsedKeyValue = replaceVariables($value);
$resource->environment_variables()->updateOrCreate([
'key' => $parsedKeyValue,
'resourceable_type' => get_class($resource),
'resourceable_id' => $resource->id,
], [
'is_preview' => false,
'is_required' => $isRequired,
'comment' => $envComments[$originalKey] ?? null,
]);
// Add the variable to the environment so it will be shown in the deployable compose file
$environment[$parsedKeyValue->value()] = $value;
continue;
}
$resource->environment_variables()->updateOrCreate([
'key' => $parsedKeyValue,
'key' => $key,
'resourceable_type' => get_class($resource),
'resourceable_id' => $resource->id,
], [
'value' => $value,
'is_preview' => false,
'is_required' => $isRequired,
'comment' => $envComments[$originalKey] ?? null,
]);
// Add the variable to the environment so it will be shown in the deployable compose file
$environment[$parsedKeyValue->value()] = $value;
continue;
}
$resource->environment_variables()->updateOrCreate([
'key' => $key,
'resourceable_type' => get_class($resource),
'resourceable_id' => $resource->id,
], [
'value' => $value,
'is_preview' => false,
'is_required' => $isRequired,
'comment' => $envComments[$originalKey] ?? null,
]);
}
}
}

View file

@ -17,9 +17,115 @@ function collectRegex(string $name)
{
return "/{$name}\w+/";
}
/**
* Extract content between balanced braces, handling nested braces properly.
*
* @param string $str The string to search
* @param int $startPos Position to start searching from
* @return array|null Array with 'content', 'start', and 'end' keys, or null if no balanced braces found
*/
function extractBalancedBraceContent(string $str, int $startPos = 0): ?array
{
// Find opening brace
$openPos = strpos($str, '{', $startPos);
if ($openPos === false) {
return null;
}
// Track depth to find matching closing brace
$depth = 1;
$pos = $openPos + 1;
$len = strlen($str);
while ($pos < $len && $depth > 0) {
if ($str[$pos] === '{') {
$depth++;
} elseif ($str[$pos] === '}') {
$depth--;
}
$pos++;
}
if ($depth !== 0) {
// Unbalanced braces
return null;
}
return [
'content' => substr($str, $openPos + 1, $pos - $openPos - 2),
'start' => $openPos,
'end' => $pos - 1,
];
}
/**
* Split variable expression on operators (:-, -, :?, ?) while respecting nested braces.
*
* @param string $content The content to split (without outer ${...})
* @return array|null Array with 'variable', 'operator', and 'default' keys, or null if no operator found
*/
function splitOnOperatorOutsideNested(string $content): ?array
{
$operators = [':-', '-', ':?', '?'];
$depth = 0;
$len = strlen($content);
for ($i = 0; $i < $len; $i++) {
if ($content[$i] === '{') {
$depth++;
} elseif ($content[$i] === '}') {
$depth--;
} elseif ($depth === 0) {
// Check for operators only at depth 0 (outside nested braces)
foreach ($operators as $op) {
if (substr($content, $i, strlen($op)) === $op) {
return [
'variable' => substr($content, 0, $i),
'operator' => $op,
'default' => substr($content, $i + strlen($op)),
];
}
}
}
}
return null;
}
function replaceVariables(string $variable): Stringable
{
return str($variable)->before('}')->replaceFirst('$', '')->replaceFirst('{', '');
// Handle ${VAR} syntax with proper brace matching
$str = str($variable);
// Handle ${VAR} format
if ($str->startsWith('${')) {
$result = extractBalancedBraceContent($variable, 0);
if ($result !== null) {
return str($result['content']);
}
// Fallback to old behavior for malformed input
return $str->before('}')->replaceFirst('$', '')->replaceFirst('{', '');
}
// Handle {VAR} format (from regex capture group without $)
if ($str->startsWith('{') && $str->endsWith('}')) {
return str(substr($variable, 1, -1));
}
// Handle {VAR format (from regex capture group, may be truncated)
if ($str->startsWith('{')) {
$result = extractBalancedBraceContent('$'.$variable, 0);
if ($result !== null) {
return str($result['content']);
}
// Fallback: remove { and get content before }
return $str->replaceFirst('{', '')->before('}');
}
return $str;
}
function getFilesystemVolumesFromServer(ServiceApplication|ServiceDatabase|Application $oneService, bool $isInit = false)

View file

@ -3692,3 +3692,55 @@ function verifyPasswordConfirmation(mixed $password, ?Livewire\Component $compon
return true;
}
/**
* Extract hard-coded environment variables from docker-compose YAML.
*
* @param string $dockerComposeRaw Raw YAML content
* @return \Illuminate\Support\Collection Collection of arrays with: key, value, comment, service_name
*/
function extractHardcodedEnvironmentVariables(string $dockerComposeRaw): \Illuminate\Support\Collection
{
if (blank($dockerComposeRaw)) {
return collect([]);
}
try {
$yaml = \Symfony\Component\Yaml\Yaml::parse($dockerComposeRaw);
} catch (\Exception $e) {
// Malformed YAML - return empty collection
return collect([]);
}
$services = data_get($yaml, 'services', []);
if (empty($services)) {
return collect([]);
}
// Extract inline comments from raw YAML
$envComments = extractYamlEnvironmentComments($dockerComposeRaw);
$hardcodedVars = collect([]);
foreach ($services as $serviceName => $service) {
$environment = collect(data_get($service, 'environment', []));
if ($environment->isEmpty()) {
continue;
}
// Convert environment variables to key-value format
$environment = convertToKeyValueCollection($environment);
foreach ($environment as $key => $value) {
$hardcodedVars->push([
'key' => $key,
'value' => $value,
'comment' => $envComments[$key] ?? null,
'service_name' => $serviceName,
]);
}
}
return $hardcodedVars;
}

View file

@ -41,19 +41,6 @@
</div>
</div>
@endif
@if ($resource->type() === 'service' || $resource?->build_pack === 'dockercompose')
<div class="flex items-center gap-1 pt-4 dark:text-warning text-coollabs">
<svg class="hidden w-4 h-4 dark:text-warning lg:block" viewBox="0 0 256 256"
xmlns="http://www.w3.org/2000/svg">
<path fill="currentColor"
d="M240.26 186.1L152.81 34.23a28.74 28.74 0 0 0-49.62 0L15.74 186.1a27.45 27.45 0 0 0 0 27.71A28.31 28.31 0 0 0 40.55 228h174.9a28.31 28.31 0 0 0 24.79-14.19a27.45 27.45 0 0 0 .02-27.71m-20.8 15.7a4.46 4.46 0 0 1-4 2.2H40.55a4.46 4.46 0 0 1-4-2.2a3.56 3.56 0 0 1 0-3.73L124 46.2a4.77 4.77 0 0 1 8 0l87.44 151.87a3.56 3.56 0 0 1 .02 3.73M116 136v-32a12 12 0 0 1 24 0v32a12 12 0 0 1-24 0m28 40a16 16 0 1 1-16-16a16 16 0 0 1 16 16">
</path>
</svg>
Hardcoded variables are not shown here.
</div>
{{-- <div class="pb-4 dark:text-warning text-coollabs">If you would like to add a variable, you must add it to
your compose file.</div> --}}
@endif
</div>
@if ($view === 'normal')
<div>
@ -66,6 +53,13 @@
@empty
<div>No environment variables found.</div>
@endforelse
@if (($resource->type() === 'service' || $resource?->build_pack === 'dockercompose') && $this->hardcodedEnvironmentVariables->isNotEmpty())
@foreach ($this->hardcodedEnvironmentVariables as $index => $env)
<livewire:project.shared.environment-variable.show-hardcoded
wire:key="hardcoded-prod-{{ $env['key'] }}-{{ $env['service_name'] ?? 'default' }}-{{ $index }}"
:env="$env" />
@endforeach
@endif
@if ($resource->type() === 'application' && $resource->environment_variables_preview->count() > 0 && $showPreview)
<div>
<h3>Preview Deployments Environment Variables</h3>
@ -75,6 +69,13 @@
<livewire:project.shared.environment-variable.show wire:key="environment-{{ $env->id }}" :env="$env"
:type="$resource->type()" />
@endforeach
@if (($resource->type() === 'service' || $resource?->build_pack === 'dockercompose') && $this->hardcodedEnvironmentVariablesPreview->isNotEmpty())
@foreach ($this->hardcodedEnvironmentVariablesPreview as $index => $env)
<livewire:project.shared.environment-variable.show-hardcoded
wire:key="hardcoded-preview-{{ $env['key'] }}-{{ $env['service_name'] ?? 'default' }}-{{ $index }}"
:env="$env" />
@endforeach
@endif
@endif
@else
<form wire:submit.prevent='submit' class="flex flex-col gap-2">

View file

@ -0,0 +1,31 @@
<div>
<div
class="flex flex-col items-center gap-4 p-4 bg-white border lg:items-start dark:bg-base dark:border-coolgray-300 border-neutral-200">
<div class="flex flex-wrap items-center gap-2">
<span
class="px-2 py-0.5 text-xs font-normal rounded dark:bg-coolgray-400/50 bg-neutral-200 dark:text-neutral-400 text-neutral-600">
Hardcoded env
</span>
@if($serviceName)
<span
class="px-2 py-0.5 text-xs font-normal rounded dark:bg-coolgray-400/50 bg-neutral-200 dark:text-neutral-400 text-neutral-600">
Service: {{ $serviceName }}
</span>
@endif
</div>
<div class="flex flex-col w-full gap-2">
<div class="flex flex-col w-full gap-2 lg:flex-row">
<x-forms.input disabled id="key" />
@if($value !== null && $value !== '')
<x-forms.input disabled type="password" value="{{ $value }}" />
@else
<x-forms.input disabled value="(inherited from host)" />
@endif
</div>
@if($comment)
<x-forms.input disabled value="{{ $comment }}" label="Comment"
helper="Documentation for this environment variable." />
@endif
</div>
</div>
</div>

View file

@ -0,0 +1,147 @@
<?php
test('extracts simple environment variables from docker-compose', function () {
$yaml = <<<'YAML'
services:
app:
image: nginx
environment:
- NODE_ENV=production
- PORT=3000
YAML;
$result = extractHardcodedEnvironmentVariables($yaml);
expect($result)->toHaveCount(2)
->and($result[0]['key'])->toBe('NODE_ENV')
->and($result[0]['value'])->toBe('production')
->and($result[0]['service_name'])->toBe('app')
->and($result[1]['key'])->toBe('PORT')
->and($result[1]['value'])->toBe('3000')
->and($result[1]['service_name'])->toBe('app');
});
test('extracts environment variables with inline comments', function () {
$yaml = <<<'YAML'
services:
app:
environment:
- NODE_ENV=production # Production environment
- DEBUG=false # Disable debug mode
YAML;
$result = extractHardcodedEnvironmentVariables($yaml);
expect($result)->toHaveCount(2)
->and($result[0]['comment'])->toBe('Production environment')
->and($result[1]['comment'])->toBe('Disable debug mode');
});
test('handles multiple services', function () {
$yaml = <<<'YAML'
services:
app:
environment:
- APP_ENV=prod
db:
environment:
- POSTGRES_DB=mydb
YAML;
$result = extractHardcodedEnvironmentVariables($yaml);
expect($result)->toHaveCount(2)
->and($result[0]['key'])->toBe('APP_ENV')
->and($result[0]['service_name'])->toBe('app')
->and($result[1]['key'])->toBe('POSTGRES_DB')
->and($result[1]['service_name'])->toBe('db');
});
test('handles associative array format', function () {
$yaml = <<<'YAML'
services:
app:
environment:
NODE_ENV: production
PORT: 3000
YAML;
$result = extractHardcodedEnvironmentVariables($yaml);
expect($result)->toHaveCount(2)
->and($result[0]['key'])->toBe('NODE_ENV')
->and($result[0]['value'])->toBe('production')
->and($result[1]['key'])->toBe('PORT')
->and($result[1]['value'])->toBe(3000); // Integer values stay as integers from YAML
});
test('handles environment variables without values', function () {
$yaml = <<<'YAML'
services:
app:
environment:
- API_KEY
- DEBUG=false
YAML;
$result = extractHardcodedEnvironmentVariables($yaml);
expect($result)->toHaveCount(2)
->and($result[0]['key'])->toBe('API_KEY')
->and($result[0]['value'])->toBe('') // Variables without values get empty string, not null
->and($result[1]['key'])->toBe('DEBUG')
->and($result[1]['value'])->toBe('false');
});
test('returns empty collection for malformed YAML', function () {
$yaml = 'invalid: yaml: content::: [[[';
$result = extractHardcodedEnvironmentVariables($yaml);
expect($result)->toBeEmpty();
});
test('returns empty collection for empty compose file', function () {
$result = extractHardcodedEnvironmentVariables('');
expect($result)->toBeEmpty();
});
test('returns empty collection when no services defined', function () {
$yaml = <<<'YAML'
version: '3.8'
networks:
default:
YAML;
$result = extractHardcodedEnvironmentVariables($yaml);
expect($result)->toBeEmpty();
});
test('returns empty collection when service has no environment section', function () {
$yaml = <<<'YAML'
services:
app:
image: nginx
YAML;
$result = extractHardcodedEnvironmentVariables($yaml);
expect($result)->toBeEmpty();
});
test('handles mixed associative and array format', function () {
$yaml = <<<'YAML'
services:
app:
environment:
- NODE_ENV=production
PORT: 3000
YAML;
$result = extractHardcodedEnvironmentVariables($yaml);
// Mixed format is invalid YAML and returns empty collection
expect($result)->toBeEmpty();
});

View file

@ -0,0 +1,220 @@
<?php
/**
* Tests for nested environment variable parsing in Docker Compose files.
*
* These tests verify that the parser correctly handles nested variable substitution syntax
* like ${API_URL:-${SERVICE_URL_YOLO}/api} where defaults can contain other variable references.
*/
test('nested variable syntax is parsed correctly', function () {
// Test the exact scenario from the bug report
$input = '${API_URL:-${SERVICE_URL_YOLO}/api}';
$result = extractBalancedBraceContent($input, 0);
expect($result)->not->toBeNull()
->and($result['content'])->toBe('API_URL:-${SERVICE_URL_YOLO}/api');
$split = splitOnOperatorOutsideNested($result['content']);
expect($split)->not->toBeNull()
->and($split['variable'])->toBe('API_URL')
->and($split['operator'])->toBe(':-')
->and($split['default'])->toBe('${SERVICE_URL_YOLO}/api');
});
test('replaceVariables correctly extracts nested variable content', function () {
// Before the fix, this would incorrectly extract only up to the first closing brace
$result = replaceVariables('${API_URL:-${SERVICE_URL_YOLO}/api}');
// Should extract the full content, not just "${API_URL:-${SERVICE_URL_YOLO"
expect($result->value())->toBe('API_URL:-${SERVICE_URL_YOLO}/api')
->and($result->value())->not->toBe('API_URL:-${SERVICE_URL_YOLO'); // Not truncated
});
test('nested defaults with path concatenation work', function () {
$input = '${REDIS_URL:-${SERVICE_URL_REDIS}/db/0}';
$result = extractBalancedBraceContent($input, 0);
$split = splitOnOperatorOutsideNested($result['content']);
expect($split['variable'])->toBe('REDIS_URL')
->and($split['default'])->toBe('${SERVICE_URL_REDIS}/db/0');
});
test('deeply nested variables are handled', function () {
// Three levels of nesting
$input = '${A:-${B:-${C}}}';
$result = extractBalancedBraceContent($input, 0);
expect($result['content'])->toBe('A:-${B:-${C}}');
$split = splitOnOperatorOutsideNested($result['content']);
expect($split['variable'])->toBe('A')
->and($split['default'])->toBe('${B:-${C}}');
});
test('multiple nested variables in default value', function () {
// Default value contains multiple variable references
$input = '${API:-${SERVICE_URL}:${SERVICE_PORT}/api}';
$result = extractBalancedBraceContent($input, 0);
$split = splitOnOperatorOutsideNested($result['content']);
expect($split['variable'])->toBe('API')
->and($split['default'])->toBe('${SERVICE_URL}:${SERVICE_PORT}/api');
});
test('nested variables with different operators', function () {
// Nested variable uses different operator
$input = '${API_URL:-${SERVICE_URL?error message}/api}';
$result = extractBalancedBraceContent($input, 0);
$split = splitOnOperatorOutsideNested($result['content']);
expect($split['variable'])->toBe('API_URL')
->and($split['operator'])->toBe(':-')
->and($split['default'])->toBe('${SERVICE_URL?error message}/api');
});
test('backward compatibility with simple variables', function () {
// Simple variable without nesting should still work
$input = '${VAR}';
$result = replaceVariables($input);
expect($result->value())->toBe('VAR');
});
test('backward compatibility with single-level defaults', function () {
// Single-level default without nesting
$input = '${VAR:-default_value}';
$result = replaceVariables($input);
expect($result->value())->toBe('VAR:-default_value');
$split = splitOnOperatorOutsideNested($result->value());
expect($split['variable'])->toBe('VAR')
->and($split['default'])->toBe('default_value');
});
test('backward compatibility with dash operator', function () {
$input = '${VAR-default}';
$result = replaceVariables($input);
$split = splitOnOperatorOutsideNested($result->value());
expect($split['operator'])->toBe('-');
});
test('backward compatibility with colon question operator', function () {
$input = '${VAR:?error message}';
$result = replaceVariables($input);
$split = splitOnOperatorOutsideNested($result->value());
expect($split['operator'])->toBe(':?')
->and($split['default'])->toBe('error message');
});
test('backward compatibility with question operator', function () {
$input = '${VAR?error}';
$result = replaceVariables($input);
$split = splitOnOperatorOutsideNested($result->value());
expect($split['operator'])->toBe('?')
->and($split['default'])->toBe('error');
});
test('SERVICE_URL magic variables in nested defaults', function () {
// Real-world scenario: SERVICE_URL_* magic variable used in nested default
$input = '${DATABASE_URL:-${SERVICE_URL_POSTGRES}/mydb}';
$result = extractBalancedBraceContent($input, 0);
$split = splitOnOperatorOutsideNested($result['content']);
expect($split['variable'])->toBe('DATABASE_URL')
->and($split['default'])->toBe('${SERVICE_URL_POSTGRES}/mydb');
// Extract the nested SERVICE_URL variable
$nestedResult = extractBalancedBraceContent($split['default'], 0);
expect($nestedResult['content'])->toBe('SERVICE_URL_POSTGRES');
});
test('SERVICE_FQDN magic variables in nested defaults', function () {
$input = '${API_HOST:-${SERVICE_FQDN_API}}';
$result = extractBalancedBraceContent($input, 0);
$split = splitOnOperatorOutsideNested($result['content']);
expect($split['default'])->toBe('${SERVICE_FQDN_API}');
$nestedResult = extractBalancedBraceContent($split['default'], 0);
expect($nestedResult['content'])->toBe('SERVICE_FQDN_API');
});
test('complex real-world example', function () {
// Complex real-world scenario from the bug report
$input = '${API_URL:-${SERVICE_URL_YOLO}/api}';
// Step 1: Extract outer variable content
$result = extractBalancedBraceContent($input, 0);
expect($result['content'])->toBe('API_URL:-${SERVICE_URL_YOLO}/api');
// Step 2: Split on operator
$split = splitOnOperatorOutsideNested($result['content']);
expect($split['variable'])->toBe('API_URL');
expect($split['operator'])->toBe(':-');
expect($split['default'])->toBe('${SERVICE_URL_YOLO}/api');
// Step 3: Extract nested variable
$nestedResult = extractBalancedBraceContent($split['default'], 0);
expect($nestedResult['content'])->toBe('SERVICE_URL_YOLO');
// This verifies that:
// 1. API_URL should be created with value "${SERVICE_URL_YOLO}/api"
// 2. SERVICE_URL_YOLO should be recognized and created as magic variable
});
test('empty nested default values', function () {
$input = '${VAR:-${NESTED:-}}';
$result = extractBalancedBraceContent($input, 0);
$split = splitOnOperatorOutsideNested($result['content']);
expect($split['default'])->toBe('${NESTED:-}');
$nestedResult = extractBalancedBraceContent($split['default'], 0);
$nestedSplit = splitOnOperatorOutsideNested($nestedResult['content']);
expect($nestedSplit['default'])->toBe('');
});
test('nested variables with complex paths', function () {
$input = '${CONFIG_URL:-${SERVICE_URL_CONFIG}/v2/config.json}';
$result = extractBalancedBraceContent($input, 0);
$split = splitOnOperatorOutsideNested($result['content']);
expect($split['default'])->toBe('${SERVICE_URL_CONFIG}/v2/config.json');
});
test('operator precedence with nesting', function () {
// The first :- at depth 0 should be used, not the one inside nested braces
$input = '${A:-${B:-default}}';
$result = extractBalancedBraceContent($input, 0);
$split = splitOnOperatorOutsideNested($result['content']);
// Should split on first :- (at depth 0)
expect($split['variable'])->toBe('A')
->and($split['operator'])->toBe(':-')
->and($split['default'])->toBe('${B:-default}'); // Not split here
});

View file

@ -0,0 +1,207 @@
<?php
use function PHPUnit\Framework\assertNotNull;
use function PHPUnit\Framework\assertNull;
test('extractBalancedBraceContent extracts content from simple variable', function () {
$result = extractBalancedBraceContent('${VAR}', 0);
assertNotNull($result);
expect($result['content'])->toBe('VAR')
->and($result['start'])->toBe(1)
->and($result['end'])->toBe(5);
});
test('extractBalancedBraceContent handles nested braces', function () {
$result = extractBalancedBraceContent('${API_URL:-${SERVICE_URL_YOLO}/api}', 0);
assertNotNull($result);
expect($result['content'])->toBe('API_URL:-${SERVICE_URL_YOLO}/api')
->and($result['start'])->toBe(1)
->and($result['end'])->toBe(34); // Position of closing }
});
test('extractBalancedBraceContent handles triple nesting', function () {
$result = extractBalancedBraceContent('${A:-${B:-${C}}}', 0);
assertNotNull($result);
expect($result['content'])->toBe('A:-${B:-${C}}');
});
test('extractBalancedBraceContent returns null for unbalanced braces', function () {
$result = extractBalancedBraceContent('${VAR', 0);
assertNull($result);
});
test('extractBalancedBraceContent returns null when no braces', function () {
$result = extractBalancedBraceContent('VAR', 0);
assertNull($result);
});
test('extractBalancedBraceContent handles startPos parameter', function () {
$result = extractBalancedBraceContent('foo ${VAR} bar', 4);
assertNotNull($result);
expect($result['content'])->toBe('VAR')
->and($result['start'])->toBe(5)
->and($result['end'])->toBe(9);
});
test('splitOnOperatorOutsideNested splits on :- operator', function () {
$split = splitOnOperatorOutsideNested('API_URL:-default_value');
assertNotNull($split);
expect($split['variable'])->toBe('API_URL')
->and($split['operator'])->toBe(':-')
->and($split['default'])->toBe('default_value');
});
test('splitOnOperatorOutsideNested handles nested defaults', function () {
$split = splitOnOperatorOutsideNested('API_URL:-${SERVICE_URL_YOLO}/api');
assertNotNull($split);
expect($split['variable'])->toBe('API_URL')
->and($split['operator'])->toBe(':-')
->and($split['default'])->toBe('${SERVICE_URL_YOLO}/api');
});
test('splitOnOperatorOutsideNested handles dash operator', function () {
$split = splitOnOperatorOutsideNested('VAR-default');
assertNotNull($split);
expect($split['variable'])->toBe('VAR')
->and($split['operator'])->toBe('-')
->and($split['default'])->toBe('default');
});
test('splitOnOperatorOutsideNested handles colon question operator', function () {
$split = splitOnOperatorOutsideNested('VAR:?error message');
assertNotNull($split);
expect($split['variable'])->toBe('VAR')
->and($split['operator'])->toBe(':?')
->and($split['default'])->toBe('error message');
});
test('splitOnOperatorOutsideNested handles question operator', function () {
$split = splitOnOperatorOutsideNested('VAR?error');
assertNotNull($split);
expect($split['variable'])->toBe('VAR')
->and($split['operator'])->toBe('?')
->and($split['default'])->toBe('error');
});
test('splitOnOperatorOutsideNested returns null for simple variable', function () {
$split = splitOnOperatorOutsideNested('SIMPLE_VAR');
assertNull($split);
});
test('splitOnOperatorOutsideNested ignores operators inside nested braces', function () {
$split = splitOnOperatorOutsideNested('A:-${B:-default}');
assertNotNull($split);
// Should split on first :- (outside nested braces), not the one inside ${B:-default}
expect($split['variable'])->toBe('A')
->and($split['operator'])->toBe(':-')
->and($split['default'])->toBe('${B:-default}');
});
test('replaceVariables handles simple variable', function () {
$result = replaceVariables('${VAR}');
expect($result->value())->toBe('VAR');
});
test('replaceVariables handles nested expressions', function () {
$result = replaceVariables('${API_URL:-${SERVICE_URL_YOLO}/api}');
expect($result->value())->toBe('API_URL:-${SERVICE_URL_YOLO}/api');
});
test('replaceVariables handles variable with default', function () {
$result = replaceVariables('${API_URL:-http://localhost}');
expect($result->value())->toBe('API_URL:-http://localhost');
});
test('replaceVariables returns unchanged for non-variable string', function () {
$result = replaceVariables('not_a_variable');
expect($result->value())->toBe('not_a_variable');
});
test('replaceVariables handles triple nesting', function () {
$result = replaceVariables('${A:-${B:-${C}}}');
expect($result->value())->toBe('A:-${B:-${C}}');
});
test('replaceVariables fallback works for malformed input', function () {
// When braces are unbalanced, it falls back to old behavior
$result = replaceVariables('${VAR');
// Old behavior would extract everything before first }
// But since there's no }, it will extract 'VAR' (removing ${)
expect($result->value())->toContain('VAR');
});
test('extractBalancedBraceContent handles complex nested expression', function () {
$result = extractBalancedBraceContent('${API:-${SERVICE_URL}/api/v${VERSION:-1}}', 0);
assertNotNull($result);
expect($result['content'])->toBe('API:-${SERVICE_URL}/api/v${VERSION:-1}');
});
test('splitOnOperatorOutsideNested handles complex nested expression', function () {
$split = splitOnOperatorOutsideNested('API:-${SERVICE_URL}/api/v${VERSION:-1}');
assertNotNull($split);
expect($split['variable'])->toBe('API')
->and($split['operator'])->toBe(':-')
->and($split['default'])->toBe('${SERVICE_URL}/api/v${VERSION:-1}');
});
test('extractBalancedBraceContent finds second variable in string', function () {
$str = '${VAR1} and ${VAR2}';
// First variable
$result1 = extractBalancedBraceContent($str, 0);
assertNotNull($result1);
expect($result1['content'])->toBe('VAR1');
// Second variable
$result2 = extractBalancedBraceContent($str, $result1['end'] + 1);
assertNotNull($result2);
expect($result2['content'])->toBe('VAR2');
});
test('replaceVariables handles empty default value', function () {
$result = replaceVariables('${VAR:-}');
expect($result->value())->toBe('VAR:-');
});
test('splitOnOperatorOutsideNested handles empty default value', function () {
$split = splitOnOperatorOutsideNested('VAR:-');
assertNotNull($split);
expect($split['variable'])->toBe('VAR')
->and($split['operator'])->toBe(':-')
->and($split['default'])->toBe('');
});
test('replaceVariables handles brace format without dollar sign', function () {
// This format is used by the regex capture group in magic variable detection
$result = replaceVariables('{SERVICE_URL_YOLO}');
expect($result->value())->toBe('SERVICE_URL_YOLO');
});
test('replaceVariables handles truncated brace format', function () {
// When regex captures {VAR from a larger expression, no closing brace
$result = replaceVariables('{API_URL');
expect($result->value())->toBe('API_URL');
});