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 BacsaiandClaude committed 2025-12-27 15:24:53 +01:00
1 parent d67fcd1dff
commit 208f0eac99
10 files changed
+1145 -87

No files matched your search

+280 -73
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,
]);
}
}
}
+107 -1
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)
+52
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;
}